@polymorphism-tech/morph-spec 8.3.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +11 -8
  2. package/bin/morph-spec.js +37 -11
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +12 -7
  5. package/docs/QUICKSTART.md +19 -10
  6. package/framework/CLAUDE.md +9 -6
  7. package/framework/MORPH.md +5 -3
  8. package/framework/commands/morph-archive.md +11 -4
  9. package/framework/commands/morph-hotfix.md +1 -1
  10. package/framework/hooks/claude-code/core/state-sync.js +41 -9
  11. package/framework/hooks/shared/state-writer.js +14 -3
  12. package/framework/schemas/feature.schema.json +17 -4
  13. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  14. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  15. package/framework/skills/core/morph-apply/SKILL.md +8 -5
  16. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  17. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  18. package/framework/skills/core/morph-hotfix/SKILL.md +5 -5
  19. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  20. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-proposal/SKILL.md +4 -4
  22. package/framework/skills/core/morph-review/SKILL.md +4 -2
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/project/doctor.js +28 -0
  27. package/src/commands/project/worktree.js +154 -357
  28. package/src/commands/state/advance.js +17 -0
  29. package/src/commands/state/approve.js +13 -0
  30. package/src/commands/state/archive.js +148 -70
  31. package/src/commands/state/create.js +67 -3
  32. package/src/commands/state/delete.js +143 -0
  33. package/src/commands/state/finish.js +422 -0
  34. package/src/commands/state/list.js +110 -0
  35. package/src/core/state/feature-identity.js +36 -0
  36. package/src/core/state/gate-order.js +51 -0
  37. package/src/core/state/lifecycle-audit.js +79 -0
  38. package/src/core/state/state-manager.js +49 -14
  39. package/src/dashboard/scanner/state-scanner.js +1 -1
  40. package/src/lib/generators/handoff-generator.js +2 -2
  41. package/src/lib/git/default-branch.js +73 -0
  42. package/src/lib/worktree/env-allocator.js +1 -1
  43. package/src/utils/file-copier.js +2 -2
@@ -14,24 +14,20 @@
14
14
  * Subcommands:
15
15
  * morph-spec worktree setup <feature> [--fresh] — create + materialize state
16
16
  * morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
17
- * morph-spec worktree finish <feature> [--keep] [--force] — merge-back + cleanup
18
17
  * morph-spec worktree list [--json] — list active feature worktrees
19
18
  *
19
+ * Closing the lifecycle (integrate + teardown) lives in `morph-spec finish`
20
+ * (src/commands/state/finish.js), which orchestrates archive → merge|PR →
21
+ * teardown for BOTH the worktree and the common flow. This file exports the
22
+ * hardened primitives it uses: `shedRootFeatureScope`, `primaryRootUnexplainedDirt`,
23
+ * and `teardownWorktree` (PROVE-or-DECLINE removal).
24
+ *
20
25
  * Exit codes (setup):
21
26
  * 0 — success (created) or fail-open (not a git repo, git error)
22
27
  * 1 — refused: feature name is not a plain kebab slug
23
28
  * 2 — worktree already exists at this exact path (signals resume to caller)
24
29
  * 3 — a live worktree for this feature already exists at a DIFFERENT path
25
30
  * (anti-dupe guard — refuses to fork a second live instance)
26
- *
27
- * Exit codes (finish):
28
- * 0 — merged successfully (or fail-open in a non-git dir is not applicable here)
29
- * 1 — refused: invalid feature name, no worktree found, the worktree is on a
30
- * different branch, Gate 3 not approved, worktree/root dirty, or merge
31
- * conflict. Every refusal is fail-CLOSED — no merge lands and no worktree
32
- * is removed. The shed runs before the root's clean check, so a refusal
33
- * from that point on has moved this feature's root-side scope to the
34
- * backup it reports; see worktreeFinishCommand.
35
31
  */
36
32
 
37
33
  import { execSync, execFileSync } from 'child_process';
@@ -238,8 +234,8 @@ const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed —
238
234
  * the infra links from `setupInfraLinks` and the fleet env manifest
239
235
  * (`.morph/worktree.env`). Same mechanism, same reasons, for both.
240
236
  *
241
- * CRITICAL — without this, `worktree finish` is unusable on POSIX and
242
- * destructive at worst, because git never sees a link as a link: it sees what
237
+ * CRITICAL — without this, `morph-spec finish` (worktree teardown) is unusable on
238
+ * POSIX and destructive at worst, because git never sees a link as a link: it sees what
243
239
  * the OS reports. A Windows junction IS a directory, so a `.morph/framework/`
244
240
  * rule (trailing slash = directories only) matches it and git ignores it. A
245
241
  * POSIX symlink is a FILE (mode 120000), so the very same rule does NOT match
@@ -631,7 +627,7 @@ export function parseWorktreeList(cwd) {
631
627
  * @param {string} root - Primary root path
632
628
  * @returns {{ path: string, branch: string, expectedBranch: string }|null}
633
629
  */
634
- function findWorktreeForFeature(feature, root) {
630
+ export function findWorktreeForFeature(feature, root) {
635
631
  const expectedBranch = buildBranchName(feature);
636
632
  const refName = `refs/heads/${expectedBranch}`;
637
633
  const entries = parseWorktreeList(root);
@@ -654,8 +650,8 @@ function findWorktreeForFeature(feature, root) {
654
650
  * `.morph/framework`, and the `project.frontendPath` node_modules if
655
651
  * configured) WITHOUT following them.
656
652
  *
657
- * CRITICAL — this is what keeps `worktree finish` from destroying the project.
658
- * `git worktree remove` refuses a worktree still holding these ("contains
653
+ * CRITICAL — this is what keeps `morph-spec finish` (worktree teardown) from
654
+ * destroying the project. `git worktree remove` refuses a worktree still holding these ("contains
659
655
  * modified or untracked files"), and the `--force` fallback FOLLOWS the
660
656
  * junctions and deletes the PRIMARY ROOT's real `.claude/` and
661
657
  * `.morph/framework/` — wiping the morph-spec installation (MORPH.md, agents,
@@ -706,8 +702,8 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
706
702
  /**
707
703
  * Every reparse point anywhere under `worktreePath`, as relative paths.
708
704
  *
709
- * CRITICAL — this is the gate that keeps `worktree finish` from destroying the
710
- * project, and it must be exhaustive. BOTH `git worktree remove` and its
705
+ * CRITICAL — this is the gate that keeps `morph-spec finish` (worktree teardown)
706
+ * from destroying the project, and it must be exhaustive. BOTH `git worktree remove` and its
711
707
  * `--force` fallback FOLLOW reparse points out of the worktree and delete the
712
708
  * PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
713
709
  * only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
@@ -795,9 +791,9 @@ const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
795
791
  */
796
792
  /**
797
793
  * Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
798
- * registration — see the call site (Validation 3 in worktreeFinishCommand)
799
- * for why this is safe to exempt from the whole-root clean gate rather than a
800
- * widened hole.
794
+ * registration — see the call site (primaryRootUnexplainedDirt, the root-clean
795
+ * gate `morph-spec finish` uses) for why this is safe to exempt from the
796
+ * whole-root clean gate rather than a widened hole.
801
797
  *
802
798
  * PROVES it rather than assuming: the path must be exactly that sibling's
803
799
  * `feature.json`, must parse, and its `worktree.path` must point at a
@@ -939,277 +935,152 @@ function backupFeatureScope(root, feature, stamp) {
939
935
  }
940
936
  }
941
937
 
942
- // ── finish ───────────────────────────────────────────────────────────────
938
+ // ── finish primitives (used by `morph-spec finish`) ───────────────────────
939
+ //
940
+ // The old `worktree finish` command dissolved here: `morph-spec finish` (see
941
+ // src/commands/state/finish.js) now orchestrates archive → integrate (merge OR
942
+ // PR) → teardown for BOTH the worktree and the common flow. These exported
943
+ // primitives keep the load-bearing safety (shed with backup, prove-or-decline
944
+ // worktree removal) in one hardened place, shared by every integration path.
943
945
 
944
946
  /**
945
- * Command handler for `morph-spec worktree finish <feature> [--keep] [--force]`.
946
- *
947
- * Deterministic merge-back: merges the feature's `morph/{feature}` branch into
948
- * the primary root and (unless `--keep`) removes the worktree + branch. This
949
- * is the closing half of the worktree lifecycle that `setup` opens.
950
- *
951
- * All three preconditions below are fail-CLOSED — no merge is attempted and
952
- * nothing is removed. They are NOT no-ops, though: the shed runs before the
953
- * last one, so a refusal there (or on a merge conflict) has already moved this
954
- * feature's root-side scope to the backup. Recoverable, and every such refusal
955
- * reports the path — but "nothing was touched" would be false. Preconditions:
956
- * 1. Gate 3 (review) approved on the worktree's own feature.json, unless `--force`.
957
- * 2. The worktree itself has no uncommitted changes (work must be committed
958
- * to travel with the merge).
959
- * 3. The primary root has no uncommitted changes (so a merge commit lands cleanly).
960
- *
961
- * NOTE: precondition 3 runs AFTER the shed of this feature's root-side scope, so
962
- * a refusal there is not a no-op — but it is RECOVERABLE: the shed copies that
963
- * scope to `.git/morph-spec/finish-backup/` first and every refusal after it
964
- * reports the path.
965
- *
966
- * That guarantee covers what the SHED discards (the root-side scope) and, at
967
- * cleanup, morph's own gitignored files in the worktree. It does NOT cover
968
- * `--discard-ignored`, which destroys the worktree's non-morph gitignored files
969
- * outright — that is the flag's stated purpose, the JSON names each file it
970
- * destroyed, and no default path reaches it.
971
- *
972
- * A merge conflict aborts the merge (`git merge --abort`) and refuses —
973
- * nothing is left half-merged.
974
- *
975
- * Success is reported as two INDEPENDENT signals: `finished` is the merge (the
976
- * actual value), `worktreeRemoved` is the best-effort cleanup. Cleanup declines
977
- * itself whenever it cannot prove it is safe, so `finished: true` +
978
- * `worktreeRemoved: false` is a normal, honest outcome — not a failure.
947
+ * Shed the feature's OWN transient root-side bookkeeping so the merge can own
948
+ * this feature's state while the whole-root clean-check keeps guarding UNRELATED
949
+ * work. Backs up FIRST (see backupFeatureScope), then discards — the shed is
950
+ * safe WITHOUT having to be right about what is in scope: if the classification
951
+ * the merge implies turns out wrong, the bytes are still on disk. Worktree-flow
952
+ * only; the common flow committed the archive on the branch, so there is no
953
+ * untracked root-side scaffold to shed.
979
954
  *
955
+ * @param {string} root
980
956
  * @param {string} feature
981
- * @param {{ keep?: boolean, force?: boolean, discardIgnored?: boolean, cwd?: string }} [options]
982
- * `force` waives the Gate 3 precondition ONLY — never the branch check.
983
- * `discardIgnored` is consent to destroy the worktree's non-morph gitignored
984
- * files (see the NOTE above); it does not waive the reparse-point gate.
957
+ * @returns {{ shedBackup: string|null }} backup path if anything was shed
985
958
  */
986
- export async function worktreeFinishCommand(feature, options = {}) {
987
- const cwd = options.cwd || process.cwd();
988
- const root = resolvePrimaryRoot(cwd);
989
-
990
- // Refuse anything that is not a plain kebab feature name. `& | ; > ^` are all
991
- // legal in a git refname but are cmd.exe metacharacters, and every git call
992
- // below interpolates the name (or the branch built from it). Validating here —
993
- // not just around the shed — is what keeps a crafted name from steering the
994
- // `merge`/`branch -d` at the bottom onto a DIFFERENT branch than the one this
995
- // command validated and reported.
996
- if (!isValidFeatureName(feature)) {
997
- console.log(JSON.stringify({ finished: false, error: 'invalid-feature-name', feature }));
998
- process.exit(1);
999
- }
1000
-
1001
- const found = findWorktreeForFeature(feature, root);
1002
- if (!found) {
1003
- console.log(JSON.stringify({ finished: false, error: 'no-worktree', feature }));
1004
- process.exit(1);
1005
- }
1006
- const { path: worktreePath, branch, expectedBranch } = found;
1007
-
1008
- // Validation 0: the worktree must actually be ON the branch we are about to
1009
- // merge. The path fallback in findWorktreeForFeature matches by location, so
1010
- // a worktree the user re-pointed at another branch (or detached) still
1011
- // matches — and merging the computed `morph/{feature}` then would land a
1012
- // branch we never validated while destroying a worktree holding unrelated
1013
- // work. `--force` does NOT waive this: it waives the review gate, not the
1014
- // question of WHICH branch this command is acting on.
1015
- if (branch !== expectedBranch) {
1016
- console.log(JSON.stringify({
1017
- finished: false,
1018
- error: 'worktree-branch-mismatch',
1019
- feature,
1020
- expectedBranch,
1021
- actualBranch: branch, // null = detached HEAD
1022
- path: worktreePath,
1023
- hint: `o worktree em ${worktreePath} está em ${branch || 'HEAD destacado'}, não em ${expectedBranch} — faça checkout da branch da feature ou finalize essa branch manualmente`,
1024
- }));
1025
- process.exit(1);
1026
- }
1027
-
1028
- // Validation 1: Gate 3 (review) approved, unless --force.
1029
- if (!options.force) {
1030
- let reviewApproved = false;
1031
- try {
1032
- const fjPath = join(worktreePath, '.morph', 'features', feature, 'feature.json');
1033
- if (existsSync(fjPath)) {
1034
- const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
1035
- const gates = fj?.approvalGates || fj?.approvals || {};
1036
- reviewApproved = gates?.review?.approved === true;
1037
- }
1038
- } catch { /* reviewApproved stays false — refuse below */ }
1039
-
1040
- if (!reviewApproved) {
1041
- console.log(JSON.stringify({ finished: false, error: 'review-not-approved', feature }));
1042
- process.exit(1);
1043
- }
1044
- }
1045
-
1046
- // Validation 2: worktree must be clean (work must be committed to travel with the merge).
1047
- let worktreeStatus;
1048
- try {
1049
- worktreeStatus = execSync('git status --porcelain', { cwd: worktreePath, stdio: 'pipe' }).toString();
1050
- } catch (err) {
1051
- console.log(JSON.stringify({ finished: false, error: 'worktree-status-failed', detail: (err.message || '').slice(0, 200), feature }));
1052
- process.exit(1);
1053
- }
1054
- if (worktreeStatus.trim() !== '') {
1055
- console.log(JSON.stringify({
1056
- finished: false,
1057
- error: 'worktree-dirty',
1058
- hint: 'commit seu trabalho na branch antes do finish',
1059
- feature,
1060
- }));
1061
- process.exit(1);
1062
- }
1063
-
1064
- // Shed the feature's OWN transient root-side bookkeeping BEFORE the clean
1065
- // check. `.morph/features/{feature}/` in the root holds artifacts the merge is
1066
- // about to replace wholesale: the factory flow (`create --worktree`) scaffolds
1067
- // it untracked, and `setup` stamps the worktree registration onto a committed
1068
- // feature.json (a ` M`). Shedding them lets the merge own this feature's state
1069
- // while the whole-root clean-check keeps guarding UNRELATED work.
1070
- //
1071
- // SAFETY — back up FIRST, then shed. An earlier design classified each dirty
1072
- // path to decide what was safe to discard; every version of that classifier
1073
- // was wrong in some real flow (an untracked file the branch has at a different
1074
- // CONTENT; a `??` feature.json that a `M`-gated check never inspected), and a
1075
- // wrong classifier here destroys unversioned work while reporting success.
1076
- // Copying the whole scope out of the working tree first makes the shed safe
1077
- // WITHOUT having to be right about what is in it: if the classification the
1078
- // merge implies turns out wrong, the bytes are still on disk.
959
+ export function shedRootFeatureScope(root, feature) {
1079
960
  const featPathspec = `.morph/features/${feature}`;
1080
- let shedBackup = null;
1081
-
1082
961
  let dirtyInScope = '';
1083
962
  try {
1084
963
  dirtyInScope = execFileSync('git', ['status', '--porcelain', '-uall', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
1085
964
  } catch { /* nothing in scope */ }
1086
965
 
1087
- if (dirtyInScope.trim() !== '') {
1088
- shedBackup = backupFeatureScope(root, feature, new Date().toISOString().replace(/[:.]/g, '-'));
1089
- // No backup, no shed. Falling through leaves the root dirty, so the
1090
- // clean-check below refuses — the exact fail-closed behaviour that existed
1091
- // before any shed did.
1092
- if (shedBackup) {
1093
- // Discard the registration stamped onto a committed feature.json by `setup`.
1094
- try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing tracked to restore */ }
1095
- // Remove the untracked scaffold left in the root by `create --worktree`.
1096
- // Scoped pathspec + no `-x` keep this away from gitignored `.morph/logs/`.
1097
- try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
1098
- }
966
+ if (dirtyInScope.trim() === '') return { shedBackup: null };
967
+
968
+ const shedBackup = backupFeatureScope(root, feature, new Date().toISOString().replace(/[:.]/g, '-'));
969
+ // No backup, no shed. Falling through leaves the root dirty, so the caller's
970
+ // clean-check refuses — the exact fail-closed behaviour from before any shed.
971
+ if (shedBackup) {
972
+ try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing tracked to restore */ }
973
+ try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
1099
974
  }
975
+ return { shedBackup };
976
+ }
1100
977
 
1101
- // Every refusal from here on must tell the user where the bytes went.
1102
- // Distinct key from `hint`: callers below carry their own hint and this must
1103
- // never overwrite it.
1104
- const backupNote = shedBackup
1105
- ? {
1106
- shedBackup,
1107
- shedBackupNote: `o conteúdo root-side de ${featPathspec} foi copiado para ${shedBackup} antes de ser descartado — nada foi perdido`,
1108
- }
1109
- : {};
1110
-
1111
- // Validation 3: primary root must be clean — except a SIBLING feature's own
1112
- // LIVE worktree registration (see isLiveSiblingWorktreeRegistration). That
1113
- // artifact is how `runWorktreeSetup`/`create --worktree` are DESIGNED to
1114
- // leave the root for that sibling's entire life (decidePersistence in
1115
- // verify.js and the anti-dupe guard above both read the root's copy for
1116
- // `.worktree.path`), so refusing THIS feature's finish over it would block
1117
- // every OTHER feature for as long as any sibling stays open in its own
1118
- // worktree — the exact "orphaned scaffold collides with finish" friction
1119
- // from real multi-feature use. Anything else is still unexplained dirt and
1120
- // still blocks, fail-closed, exactly as before.
978
+ /**
979
+ * The primary root's uncommitted paths that are NOT explained — everything
980
+ * except a SIBLING feature's own LIVE worktree registration (see
981
+ * isLiveSiblingWorktreeRegistration, the exemption that keeps one open sibling
982
+ * from blocking every other feature's finish). `ok:false` means the status call
983
+ * itself failed; an empty `dirt` means clean enough to merge into.
984
+ *
985
+ * @param {string} root
986
+ * @param {string} feature - the feature THIS finish acts on (its own scope is the shed's job)
987
+ * @returns {{ ok: boolean, dirt: string[] }}
988
+ */
989
+ export function primaryRootUnexplainedDirt(root, feature) {
1121
990
  let rootStatusRaw;
1122
991
  try {
1123
- // `--untracked-files=all`, not the default `normal`: the default collapses
1124
- // an entirely-untracked directory into one entry (e.g. `.morph/features/
1125
- // sibling-feat/`, verified empirically) — which would never match the
1126
- // exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects,
1127
- // silently defeating the exemption for the very shape it exists to allow.
992
+ // `--untracked-files=all`, not the default: the default collapses an
993
+ // entirely-untracked directory into one entry, which would never match the
994
+ // exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects.
1128
995
  rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
1129
- } catch (err) {
1130
- console.log(JSON.stringify({ finished: false, error: 'primary-root-status-failed', detail: (err.message || '').slice(0, 200), feature, ...backupNote }));
1131
- process.exit(1);
996
+ } catch {
997
+ return { ok: false, dirt: [] };
1132
998
  }
1133
- const unexplainedDirt = rootStatusRaw.split('\0').filter((record) => {
999
+ const dirt = rootStatusRaw.split('\0').filter((record) => {
1134
1000
  if (!record || record.length < 4) return false;
1135
1001
  const p = record.slice(3).replace(/\\/g, '/');
1136
1002
  return !isLiveSiblingWorktreeRegistration(p, root, feature);
1137
1003
  });
1138
- if (unexplainedDirt.length) {
1139
- console.log(JSON.stringify({
1140
- finished: false,
1141
- error: 'primary-root-dirty',
1142
- feature,
1143
- ...backupNote,
1144
- }));
1145
- process.exit(1);
1146
- }
1004
+ return { ok: true, dirt };
1005
+ }
1147
1006
 
1148
- // Merge — fail-closed on conflict. Args go through execFile: the branch name
1149
- // reaches git as one argv entry, never as a cmd.exe string to re-split.
1007
+ /**
1008
+ * Clear the root-side `.worktree` registration for a finished feature and commit
1009
+ * that cleanup scoped to its own pathspec. Runs whenever the worktree is
1010
+ * REMOVED — including `--pr`, where no merge landed — so a stale registration
1011
+ * never trips the anti-dupe guard or the next finish's root-clean.
1012
+ *
1013
+ * Written DIRECTLY (never saveState, which re-persists every sibling's
1014
+ * feature.json and can dirty the root outside this command's pathspec). The thin
1015
+ * root index carries only {status, updatedAt}, never `worktree`. Fail-open:
1016
+ * metadata cleanup is never a reason to fail an integration that already landed.
1017
+ *
1018
+ * @param {string} root
1019
+ * @param {string} feature
1020
+ */
1021
+ async function clearRootWorktreeRegistration(root, feature) {
1022
+ const featPathspec = `.morph/features/${feature}`;
1150
1023
  try {
1151
- execFileSync('git', ['merge', '--no-ff', branch, '-m', `morph-spec worktree finish: merge ${branch}`], { cwd: root, stdio: 'pipe' });
1152
- } catch (err) {
1153
- try {
1154
- execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
1155
- } catch { /* best-effort abort — merge may not have entered a mergeable state */ }
1156
- const output = (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
1157
- console.log(JSON.stringify({
1158
- finished: false,
1159
- error: 'merge-conflict',
1160
- output,
1161
- hint: 'resolva manualmente ou re-execute a feature sequencialmente',
1162
- feature,
1163
- ...backupNote,
1164
- }));
1165
- process.exit(1);
1166
- }
1024
+ const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
1025
+ if (existsSync(rootFjPath)) {
1026
+ const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
1027
+ if (fj.worktree) {
1028
+ delete fj.worktree;
1029
+ const { atomicWriteJson } = await import('../../core/state/state-manager.js');
1030
+ atomicWriteJson(rootFjPath, fj);
1031
+ }
1032
+ }
1033
+ } catch { /* fail-open */ }
1167
1034
 
1168
- if (options.keep) {
1169
- console.log(JSON.stringify({ finished: true, kept: true, feature, merged: branch, path: worktreePath, branch, ...backupNote }));
1170
- process.exit(0);
1171
- }
1035
+ // Clearing that field can itself dirty the root (a committed registration
1036
+ // survives the shed's checkout and the merge). Committing is in contract —
1037
+ // finish already writes commits — and scoped to this feature's path only.
1038
+ try {
1039
+ const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
1040
+ if (dirtyAfter.trim() !== '') {
1041
+ execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
1042
+ execFileSync('git', ['commit', '-m', `morph-spec finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
1043
+ }
1044
+ } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
1045
+ }
1172
1046
 
1173
- // ── Cleanup: PROVE it is safe, or decline it ──────────────────────────────
1174
- //
1175
- // The merge (the actual value of this command) has landed. Removing the
1176
- // worktree is convenience, and it is the single most dangerous thing here.
1177
- // Two independent ways it destroys data, both of which end in a removal that
1178
- // exits 0 while doing it:
1179
- //
1180
- // 1. `git worktree remove` — plain AND `--force` — follows reparse points
1181
- // out of the worktree and deletes what they point at in the primary root.
1182
- // 2. It deletes GITIGNORED files outright. Git considers them disposable;
1183
- // a user's `.env` is not. They are invisible to the clean-check above
1184
- // (`git status --porcelain` omits them by design), so that gate cannot
1185
- // speak for them at all.
1186
- //
1187
- // Both gates below therefore PROVE-or-DECLINE. Declining costs a stale
1188
- // directory the user can delete in one command; being wrong costs data that
1189
- // exists nowhere else.
1047
+ /**
1048
+ * Remove a feature's worktree after its branch is integrated — PROVE-or-DECLINE.
1049
+ * The single most dangerous step in the lifecycle, so it declines whenever it
1050
+ * cannot prove removal is safe. Two independent ways `git worktree remove`
1051
+ * (plain AND `--force`) destroys data, both ending in an exit-0 removal:
1052
+ * 1. it FOLLOWS reparse points out of the worktree and deletes what they point
1053
+ * at in the primary root (the real `.claude/`, `.morph/framework/`);
1054
+ * 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
1055
+ * and the clean-check cannot see them.
1056
+ *
1057
+ * `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
1058
+ * branch is integrated), false for `--pr` (the open PR still needs the branch).
1059
+ *
1060
+ * @param {string} worktreePath
1061
+ * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
1062
+ * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
1063
+ * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null }>}
1064
+ */
1065
+ export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
1066
+ // Drop the infra junctions FIRST, without following them — this is what makes
1067
+ // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1190
1068
  unlinkWorktreeInfraLinks(worktreePath, root);
1191
1069
 
1192
1070
  const scan = findReparsePoints(worktreePath);
1193
1071
  const ignored = classifyIgnoredLeftovers(worktreePath, options);
1194
1072
  const blockers = [...scan.points, ...scan.unprovable];
1195
1073
 
1196
- // Back up ONLY once the removal is otherwise cleared. Two reasons, and the
1197
- // ordering buys both for free:
1198
- // - Nothing is about to be destroyed on a declined path, so copying there
1199
- // is pure waste — and worse, it would duplicate the user's content into
1200
- // `.git/` for a removal that never happens.
1201
- // - `blockers.length === 0` is a PROOF that no reparse point exists anywhere
1202
- // under the worktree. cpSync follows junctions on Windows, so running it
1203
- // before that proof could copy an out-of-repo tree of unbounded size in
1204
- // under an allowlisted `.morph/` path.
1074
+ // Back up ONLY once the removal is otherwise cleared: nothing is destroyed on
1075
+ // a declined path (copying there is waste), and `blockers.length === 0` proves
1076
+ // no reparse point exists — cpSync follows junctions, so backing up before
1077
+ // that proof could copy an out-of-repo tree of unbounded size.
1205
1078
  let ignoredBackup = null;
1206
1079
  const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
1207
1080
  if (clearedToRemove && ignored.shieldable.length) {
1208
1081
  ignoredBackup = backupWorktreeIgnored(worktreePath, root, feature, ignored.shieldable, new Date().toISOString().replace(/[:.]/g, '-'));
1209
1082
  }
1210
-
1211
- const canRemove = clearedToRemove
1212
- && (!ignored.shieldable.length || ignoredBackup !== null);
1083
+ const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
1213
1084
 
1214
1085
  let worktreeRemoved = false;
1215
1086
  if (canRemove) {
@@ -1222,120 +1093,46 @@ export async function worktreeFinishCommand(feature, options = {}) {
1222
1093
  try {
1223
1094
  execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
1224
1095
  worktreeRemoved = true;
1225
- } catch { /* fail-open — cleanup is best-effort once the merge already landed */ }
1096
+ } catch { /* fail-open — cleanup is best-effort once integration landed */ }
1226
1097
  }
1227
1098
  }
1228
1099
 
1229
- // The branch is still checked out by a worktree we did not remove — deleting
1230
- // it would fail anyway, and `--worktree prune` has nothing to reclaim.
1231
1100
  if (worktreeRemoved) {
1232
- try {
1233
- execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' });
1234
- } catch { /* fail-open */ }
1235
-
1236
- try {
1237
- execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
1238
- } catch { /* fail-open */ }
1239
-
1240
- // Fleet env: the worktree is gone, so no server can be running on its
1241
- // block — release it for the next feature. ONLY here: a kept/declined
1242
- // worktree may still be serving on those ports. Fail-open metadata cleanup.
1101
+ // Three independent axes: remove-worktree (done), delete-branch (mode), and
1102
+ // release-env (worktree existed and is gone). `--pr` removes the worktree
1103
+ // but keeps the branch the open PR needs.
1104
+ if (deleteBranch) {
1105
+ try { execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' }); } catch { /* fail-open */ }
1106
+ }
1107
+ try { execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' }); } catch { /* fail-open */ }
1108
+ // Fleet env: the worktree is gone, so no server can be running on its block.
1243
1109
  try {
1244
1110
  const { releaseEnvBlock } = await import('../../lib/worktree/env-allocator.js');
1245
1111
  const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
1246
1112
  if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
1247
1113
  } catch { /* fail-open */ }
1114
+ await clearRootWorktreeRegistration(root, feature);
1248
1115
  }
1249
1116
 
1250
- // The feature is now merged into the primary root — clear the `worktree`
1251
- // bookkeeping field from its (root-side) feature.json. Fail-open: this is
1252
- // metadata cleanup, never a reason to report the finish itself as failed.
1253
- //
1254
- // Written DIRECTLY, not via loadState/saveState: saveState re-persists EVERY
1255
- // feature's feature.json, so clearing one field on THIS feature rewrites the
1256
- // siblings too. Any sibling whose committed file differs only in formatting (a
1257
- // trailing newline — what an editor or prettier leaves) gets rewritten into a
1258
- // ` M`, dirtying the root outside this command's own pathspec, where the
1259
- // post-merge commit below cannot see it. That stray modification then blocks
1260
- // the NEXT feature's finish — the exact failure this command exists to avoid,
1261
- // and it bites hardest in the parallel multi-worktree flow it was built for.
1262
- // The thin root index carries only {status, updatedAt}, never `worktree`, so
1263
- // there is nothing else to persist here.
1264
- // atomicWriteJson (not a plain writeFileSync): this file is the feature's
1265
- // AUTHORITATIVE, committed state. A torn write from a crash — or an EPERM
1266
- // from a Windows AV/indexer holding the handle — would corrupt or fail on the
1267
- // one copy that matters. tmp+rename with the EPERM retry is what the rest of
1268
- // the state layer uses; bypassing the sweep must not also bypass that.
1269
- try {
1270
- const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
1271
- if (existsSync(rootFjPath)) {
1272
- const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
1273
- if (fj.worktree) {
1274
- delete fj.worktree;
1275
- const { atomicWriteJson } = await import('../../core/state/state-manager.js');
1276
- atomicWriteJson(rootFjPath, fj);
1277
- }
1278
- }
1279
- } catch { /* fail-open */ }
1280
-
1281
- // Clearing that field can itself dirty the root: when `setup`'s registration
1282
- // was COMMITTED, it lives in HEAD, so the shed's `git checkout` cannot undo it
1283
- // and it survives the merge — then the rewrite above leaves a ` M`. Left
1284
- // behind, that stray modification blocks the NEXT feature's finish (the shed
1285
- // is scoped to its own feature), which is the very bug this command was fixed
1286
- // to stop hitting. Committing it is in contract: `finish` already writes a
1287
- // merge commit. Scoped to this feature's path — never sweeps up other work.
1288
- try {
1289
- const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
1290
- if (dirtyAfter.trim() !== '') {
1291
- execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
1292
- execFileSync('git', ['commit', '-m', `morph-spec worktree finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
1293
- }
1294
- } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail a landed merge */ }
1295
-
1296
- const declineReason = ignored.blocking.length
1297
- ? `merge OK. O worktree NÃO foi removido: ele 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}"\` + \`git branch -d ${branch}\`, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
1298
- : blockers.length
1299
- ? `merge OK. O worktree NÃO foi removido: ele 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}"\` + \`git branch -d ${branch}\``
1300
- : ignored.shieldable.length && ignoredBackup === null
1301
- ? `merge OK. O worktree NÃO foi removido: o backup de ${featPathspec} (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`
1302
- : `merge OK, mas o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\` manualmente`;
1303
-
1304
- console.log(JSON.stringify({
1305
- finished: true,
1306
- feature,
1307
- merged: branch,
1117
+ const declineReason = worktreeRemoved
1118
+ ? null
1119
+ : ignored.blocking.length
1120
+ ? `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`
1121
+ : blockers.length
1122
+ ? `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}\`` : ''}`
1123
+ : ignored.shieldable.length && ignoredBackup === null
1124
+ ? `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`
1125
+ : `o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` manualmente`;
1126
+
1127
+ return {
1308
1128
  worktreeRemoved,
1309
- ...(shedBackup ? { shedBackup } : {}),
1310
- ...(ignoredBackup ? { worktreeIgnoredBackup: ignoredBackup } : {}),
1311
- // Only when the removal actually ran did these die — a decline for any other
1312
- // reason leaves them on disk. Naming them keeps `--discard-ignored` honest:
1313
- // `worktreeIgnoredBackup` alone would imply everything was preserved.
1314
- ...(worktreeRemoved && ignored.discarded.length
1315
- ? {
1316
- discardedIgnored: ignored.discarded,
1317
- discardedNote: options.discardIgnored
1318
- ? `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`
1319
- : `artefatos de build reconhecidos (bin/, obj/, node_modules/, *.tsbuildinfo) — descartados por padrão, sem backup (regeneráveis a partir do source)`,
1320
- }
1321
- : {}),
1322
- ...(worktreeRemoved
1323
- ? {}
1324
- : {
1325
- path: worktreePath,
1326
- branch,
1327
- ...(blockers.length ? { reparsePoints: blockers } : {}),
1328
- ...(ignored.blocking.length ? { ignoredFiles: ignored.blocking } : {}),
1329
- cleanupHint: declineReason,
1330
- // The infra junctions were dropped before this decline (that step runs
1331
- // unconditionally, since it is what makes a removal survivable). A kept
1332
- // worktree therefore has no node_modules/.claude until they are relinked
1333
- // — say so, instead of leaving the user to discover a broken worktree.
1334
- relinkHint: `as junctions de infra (node_modules, .claude, .morph/framework) foram soltas antes desta recusa — se for continuar trabalhando nesse worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``,
1335
- }),
1336
- nextStep: `morph-spec archive ${feature}`,
1337
- }));
1338
- process.exit(0);
1129
+ ignoredBackup,
1130
+ discarded: ignored.discarded,
1131
+ blockers,
1132
+ ignoredBlocking: ignored.blocking,
1133
+ shieldable: ignored.shieldable,
1134
+ declineReason,
1135
+ };
1339
1136
  }
1340
1137
 
1341
1138
  // ── list ─────────────────────────────────────────────────────────────────