@polymorphism-tech/morph-spec 8.47.0 → 9.1.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 (377) hide show
  1. package/CLAUDE.md +51 -43
  2. package/README.md +39 -38
  3. package/bin/morph-spec.js +41 -113
  4. package/bin/validate.js +1 -147
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +86 -21
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-status.md +4 -4
  17. package/framework/docs/CLI.md +574 -286
  18. package/framework/evals/review-checklist.md +82 -41
  19. package/framework/hooks/README.md +10 -11
  20. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  21. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  22. package/framework/hooks/claude-code/core/chain.js +4 -65
  23. package/framework/hooks/claude-code/core/comment-guard.js +3 -17
  24. package/framework/hooks/claude-code/core/denylist.json +33 -3
  25. package/framework/hooks/claude-code/core/enforce-phase-writes.js +15 -63
  26. package/framework/hooks/claude-code/core/gate-guard.js +9 -78
  27. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  28. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  29. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  30. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  31. package/framework/hooks/claude-code/core/pre-bash-denylist.js +36 -2
  32. package/framework/hooks/claude-code/core/protect-spec-files.js +25 -297
  33. package/framework/hooks/claude-code/core/state-sync.js +15 -85
  34. package/framework/hooks/claude-code/core/telemetry-log.js +15 -96
  35. package/framework/hooks/claude-code/core/validate-completion.js +37 -147
  36. package/framework/hooks/claude-code/core/warn-bash-source-write.js +23 -0
  37. package/framework/hooks/claude-code/core/worktree-context.js +15 -114
  38. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  39. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  40. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  41. package/framework/hooks/claude-code/statusline.py +55 -41
  42. package/framework/hooks/package.json +3 -0
  43. package/framework/hooks/shared/activity-logger.js +9 -89
  44. package/framework/hooks/shared/agent-reap.js +2 -17
  45. package/framework/hooks/shared/build-check.js +11 -76
  46. package/framework/hooks/shared/claim-registry.js +9 -119
  47. package/framework/hooks/shared/command-tokens.js +168 -44
  48. package/framework/hooks/shared/comment-rules.js +5 -20
  49. package/framework/hooks/shared/comment-scanner.js +11 -43
  50. package/framework/hooks/shared/comment-stripper.js +11 -85
  51. package/framework/hooks/shared/e2e-check.js +0 -0
  52. package/framework/hooks/shared/errata.js +59 -0
  53. package/framework/hooks/shared/feature-locator.js +11 -95
  54. package/framework/hooks/shared/frontend-locations.js +4 -0
  55. package/framework/hooks/shared/gate-question.js +12 -0
  56. package/framework/hooks/shared/git-exclude.js +3 -20
  57. package/framework/hooks/shared/git-paths.js +8 -104
  58. package/framework/hooks/shared/graph-freshness.js +22 -166
  59. package/framework/hooks/shared/graph-refresh.js +21 -149
  60. package/framework/hooks/shared/heavy-command.js +4 -12
  61. package/framework/hooks/shared/hook-response.js +8 -83
  62. package/framework/hooks/shared/morph-cli.js +8 -52
  63. package/framework/hooks/shared/payload-utils.js +10 -132
  64. package/framework/hooks/shared/phase-utils.js +14 -72
  65. package/framework/hooks/shared/redact-secrets.js +4 -15
  66. package/framework/hooks/shared/reparse-scan.js +6 -66
  67. package/framework/hooks/shared/slice-progress.js +99 -0
  68. package/framework/hooks/shared/source-files.js +4 -17
  69. package/framework/hooks/shared/spawn-command.js +23 -0
  70. package/framework/hooks/shared/state-reader.js +51 -247
  71. package/framework/hooks/shared/state-writer.js +9 -54
  72. package/framework/hooks/shared/stdin-reader.js +1 -11
  73. package/framework/hooks/shared/telemetry-logger.js +7 -67
  74. package/framework/hooks/shared/transcript-usage.js +7 -29
  75. package/framework/hooks/shared/worktree-root.js +7 -29
  76. package/framework/mcp/registry.json +3 -4
  77. package/framework/phases.json +23 -39
  78. package/framework/rules/csharp-standards.md +1 -1
  79. package/framework/rules/frontend-standards.md +1 -0
  80. package/framework/rules/hangfire-standards.md +1 -1
  81. package/framework/rules/neon-standards.md +1 -1
  82. package/framework/schemas/config.schema.json +14 -9
  83. package/framework/schemas/feature.schema.json +15 -50
  84. package/framework/skills/README.md +3 -4
  85. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  86. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  87. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  88. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  89. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  90. package/framework/skills/core/morph-media/SKILL.md +1 -1
  91. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  92. package/framework/skills/core/morph-review/SKILL.md +111 -136
  93. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  94. package/framework/skills/core/morph-uiux/SKILL.md +94 -152
  95. package/framework/standards/STANDARDS.json +109 -1219
  96. package/framework/standards/STANDARDS.meta.json +1319 -0
  97. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  98. package/framework/standards/ai-agents/agent-session.md +1 -1
  99. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  100. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  101. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  102. package/framework/standards/ai-agents/guardrails.md +1 -1
  103. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  104. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  105. package/framework/standards/ai-agents/production.md +1 -1
  106. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  107. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  108. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  109. package/framework/standards/ai-agents/setup.md +3 -3
  110. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  111. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  112. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  113. package/framework/standards/ai-agents/workflows.md +1 -1
  114. package/framework/standards/architecture/code-comments.md +7 -7
  115. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  116. package/framework/standards/backend/api/validation.md +1 -1
  117. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  118. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  119. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  120. package/framework/standards/data/nosql/cache/redis.md +1 -1
  121. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  122. package/framework/standards/frontend/admin-app/forms.md +1 -1
  123. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  124. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  125. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  126. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  127. package/framework/templates/CLAUDE.md.core.template +3 -3
  128. package/framework/templates/README.md +2 -3
  129. package/framework/templates/REGISTRY.json +15 -179
  130. package/framework/templates/docs/plan.md +72 -0
  131. package/framework/templates/docs/ui-mockups.md +4 -0
  132. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  133. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  134. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  135. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  136. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  137. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  138. package/package.json +1 -1
  139. package/src/commands/context/context-refresh.js +0 -16
  140. package/src/commands/dashboard/dashboard.js +2 -8
  141. package/src/commands/e2e/e2e.js +18 -105
  142. package/src/commands/graph/affected.js +0 -14
  143. package/src/commands/graph/build.js +11 -50
  144. package/src/commands/graph/explain.js +0 -16
  145. package/src/commands/graph/status.js +1 -12
  146. package/src/commands/mcp/mcp-setup.js +4 -62
  147. package/src/commands/observability/cost.js +17 -111
  148. package/src/commands/observability/telemetry.js +1 -14
  149. package/src/commands/project/branch.js +4 -45
  150. package/src/commands/project/claim.js +4 -48
  151. package/src/commands/project/doctor-procs.js +1 -1
  152. package/src/commands/project/doctor.js +53 -307
  153. package/src/commands/project/env.js +1 -27
  154. package/src/commands/project/fleet.js +6 -35
  155. package/src/commands/project/heavy.js +3 -3
  156. package/src/commands/project/init.js +8 -68
  157. package/src/commands/project/install-plugin-cmd.js +0 -1
  158. package/src/commands/project/setup-infra-cmd.js +2 -1
  159. package/src/commands/project/stack-scan.js +58 -0
  160. package/src/commands/project/standards.js +65 -72
  161. package/src/commands/project/status.js +28 -121
  162. package/src/commands/project/update.js +31 -223
  163. package/src/commands/project/worktree.js +109 -883
  164. package/src/commands/scaffold/scaffold.js +0 -25
  165. package/src/commands/state/advance.js +65 -120
  166. package/src/commands/state/approve.js +87 -229
  167. package/src/commands/state/archive.js +23 -139
  168. package/src/commands/state/auto-approval.js +65 -0
  169. package/src/commands/state/create.js +49 -109
  170. package/src/commands/state/delete.js +11 -63
  171. package/src/commands/state/finish.js +105 -494
  172. package/src/commands/state/gate-check.js +74 -183
  173. package/src/commands/state/gate-decision.js +8 -42
  174. package/src/commands/state/handoff.js +4 -31
  175. package/src/commands/state/list.js +21 -91
  176. package/src/commands/state/report-check.js +134 -338
  177. package/src/commands/state/retype.js +12 -28
  178. package/src/commands/validation/index.js +0 -3
  179. package/src/commands/validation/mutate.js +79 -19
  180. package/src/commands/validation/plan-check.js +81 -0
  181. package/src/commands/validation/validate-feature.js +52 -216
  182. package/src/commands/validation/verify.js +45 -160
  183. package/src/core/paths/output-schema.js +9 -155
  184. package/src/core/state/feature-identity.js +1 -25
  185. package/src/core/state/gate-order.js +2 -30
  186. package/src/core/state/lifecycle-audit.js +8 -100
  187. package/src/core/state/state-manager.js +32 -395
  188. package/src/core/templates/template-registry.js +2 -97
  189. package/src/core/templates/template-renderer.js +13 -93
  190. package/src/core/templates/template-seed.js +21 -0
  191. package/src/core/work-types.js +10 -45
  192. package/src/dashboard/app/App.jsx +1 -8
  193. package/src/dashboard/app/components/ActivityFeed.jsx +2 -8
  194. package/src/dashboard/app/components/CommandPalette.jsx +4 -11
  195. package/src/dashboard/app/components/DetailPanel.jsx +1 -5
  196. package/src/dashboard/app/components/EntityLink.jsx +0 -12
  197. package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
  198. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
  199. package/src/dashboard/app/components/Markdown.jsx +0 -9
  200. package/src/dashboard/app/components/PhaseRail.jsx +0 -11
  201. package/src/dashboard/app/components/SearchFilter.jsx +0 -4
  202. package/src/dashboard/app/components/Sidebar.jsx +0 -5
  203. package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
  204. package/src/dashboard/app/hooks/usePref.js +1 -5
  205. package/src/dashboard/app/lib/format.js +0 -2
  206. package/src/dashboard/app/lib/phases.js +3 -9
  207. package/src/dashboard/app/lib/project.js +0 -12
  208. package/src/dashboard/app/pages/Activity.jsx +1 -1
  209. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  210. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -133
  211. package/src/dashboard/app/pages/Features.jsx +9 -16
  212. package/src/dashboard/app/pages/Guide.jsx +2 -3
  213. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  214. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -7
  215. package/src/dashboard/app/pages/Portfolio.jsx +0 -1
  216. package/src/dashboard/app/pages/RulesMap.jsx +1 -16
  217. package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
  218. package/src/dashboard/app/pages/Standards.jsx +2 -17
  219. package/src/dashboard/scanner/activity-scanner.js +14 -83
  220. package/src/dashboard/scanner/agents-scanner.js +1 -23
  221. package/src/dashboard/scanner/docs-scanner.js +1 -7
  222. package/src/dashboard/scanner/feature-status.js +2 -5
  223. package/src/dashboard/scanner/features-scanner.js +92 -113
  224. package/src/dashboard/scanner/hooks-scanner.js +5 -28
  225. package/src/dashboard/scanner/index.js +0 -4
  226. package/src/dashboard/scanner/portfolio.js +5 -29
  227. package/src/dashboard/scanner/project-api.js +8 -54
  228. package/src/dashboard/scanner/rules-scanner.js +1 -2
  229. package/src/dashboard/scanner/skills-scanner.js +2 -12
  230. package/src/dashboard/scanner/standards-scanner.js +4 -7
  231. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  232. package/src/lib/ai/ai-checks.js +7 -52
  233. package/src/lib/ai/ai-project-scan.js +14 -108
  234. package/src/lib/ai/code-audit.js +7 -63
  235. package/src/lib/ai/pin-drift.js +8 -51
  236. package/src/lib/ai/registry-audit.js +11 -90
  237. package/src/lib/ci/ci-verify.js +14 -13
  238. package/src/lib/ci/github-ci.js +9 -37
  239. package/src/lib/ci/workflow-scaffold.js +2 -10
  240. package/src/lib/detectors/claude-config-detector.js +13 -74
  241. package/src/lib/detectors/index.js +8 -59
  242. package/src/lib/e2e/compose-env.js +5 -66
  243. package/src/lib/e2e/stack-teardown.js +6 -40
  244. package/src/lib/generators/architecture-scanner.js +18 -156
  245. package/src/lib/generators/handoff-generator.js +37 -112
  246. package/src/lib/generators/scaffold-generator.js +5 -101
  247. package/src/lib/generators/settings-generator.js +11 -122
  248. package/src/lib/git/branch-classifier.js +11 -84
  249. package/src/lib/git/default-branch.js +14 -169
  250. package/src/lib/git/evidence-staleness.js +2 -1
  251. package/src/lib/git/gh.js +2 -17
  252. package/src/lib/git/pending-prs.js +6 -37
  253. package/src/lib/git/pr-files.js +3 -28
  254. package/src/lib/git/pr-index.js +3 -34
  255. package/src/lib/git/tree-fingerprint.js +17 -17
  256. package/src/lib/git/tree-state.js +6 -33
  257. package/src/lib/graph/god-nodes.js +2 -22
  258. package/src/lib/graph/graph-health.js +3 -24
  259. package/src/lib/graph/graph-loader.js +5 -56
  260. package/src/lib/graph/graphify-runtime.js +7 -37
  261. package/src/lib/graph/graphifyignore.js +8 -74
  262. package/src/lib/graph/sanitize.js +16 -136
  263. package/src/lib/heavy/cleanup.js +2 -6
  264. package/src/lib/installers/effects.js +8 -93
  265. package/src/lib/installers/mcp-installer.js +9 -169
  266. package/src/lib/installers/memory-installer.js +8 -33
  267. package/src/lib/installers/templates-reconcile.js +5 -60
  268. package/src/lib/mutation/commands.js +3 -2
  269. package/src/lib/mutation/dotnet-introspection.js +281 -0
  270. package/src/lib/mutation/plan.js +14 -6
  271. package/src/lib/mutation/runner.js +1 -1
  272. package/src/lib/mutation/subset.js +47 -13
  273. package/src/lib/plan/artifact-vocabulary.js +40 -0
  274. package/src/lib/plan/checks.js +208 -0
  275. package/src/lib/plan/plan-parser.js +204 -0
  276. package/src/lib/plan/plan-template.js +18 -0
  277. package/src/lib/projects/registry.js +7 -44
  278. package/src/lib/review-reports.js +204 -0
  279. package/src/lib/risk-detector.js +131 -274
  280. package/src/lib/specs/adr-index.js +38 -85
  281. package/src/lib/specs/design-seed.js +18 -0
  282. package/src/lib/specs/frontmatter.js +31 -0
  283. package/src/lib/specs/gfm-table.js +11 -0
  284. package/src/lib/specs/revocations.js +31 -102
  285. package/src/lib/specs/screen-checklist.js +56 -132
  286. package/src/lib/stack/dotnet-build-ignores.js +43 -0
  287. package/src/lib/stack/stack-profile.js +1 -28
  288. package/src/lib/stack/stack-scan.js +260 -0
  289. package/src/lib/stack-filter.js +4 -27
  290. package/src/lib/standards/registry.js +37 -0
  291. package/src/lib/standards/sections.js +47 -0
  292. package/src/lib/standards/verified-against.js +19 -191
  293. package/src/lib/tasks/eval-node.js +23 -179
  294. package/src/lib/tasks/linux-runner.js +18 -142
  295. package/src/lib/tasks/test-nodes.js +43 -313
  296. package/src/lib/tasks/test-runner.js +107 -447
  297. package/src/lib/telemetry/metrics.js +13 -103
  298. package/src/lib/telemetry/telemetry-reader.js +13 -238
  299. package/src/lib/telemetry/verify-event.js +4 -34
  300. package/src/lib/validators/code-budget/code-budget-validator.js +4 -44
  301. package/src/lib/validators/code-budget/index.js +0 -7
  302. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -26
  303. package/src/lib/validators/contracts/index.js +0 -6
  304. package/src/lib/validators/nextjs/index.js +0 -5
  305. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  306. package/src/lib/validators/packages/index.js +0 -6
  307. package/src/lib/validators/packages/package-validator.js +6 -56
  308. package/src/lib/validators/reuse/index.js +0 -8
  309. package/src/lib/validators/reuse/reuse-validator.js +24 -137
  310. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  311. package/src/lib/validators/traceability-check.js +54 -156
  312. package/src/lib/validators/validation-runner.js +20 -125
  313. package/src/lib/validators/verify-runner.js +152 -575
  314. package/src/lib/work-type-detector.js +5 -67
  315. package/src/lib/worktree/docker-audit.js +17 -137
  316. package/src/lib/worktree/env-allocator.js +10 -98
  317. package/src/lib/worktree/feature-truth.js +12 -115
  318. package/src/lib/worktree/finish-backup.js +32 -242
  319. package/src/lib/worktree/fleet-scanner.js +31 -126
  320. package/src/lib/worktree/ignored-triage.js +15 -91
  321. package/src/lib/worktree/vscode-settings.js +2 -3
  322. package/src/lib/worktree/worktree-audit.js +21 -213
  323. package/src/lib/worktree/worktree-include.js +3 -5
  324. package/src/scripts/global-install.js +3 -9
  325. package/src/scripts/install-plugin.js +1 -13
  326. package/src/scripts/setup-infra.js +17 -138
  327. package/src/utils/agents-installer.js +92 -272
  328. package/src/utils/banner.js +4 -25
  329. package/src/utils/claude-md-injector.js +1 -37
  330. package/src/utils/claude-settings-manager.js +1 -87
  331. package/src/utils/file-copier.js +32 -90
  332. package/src/utils/hooks-installer.js +26 -296
  333. package/src/utils/skills-installer.js +4 -102
  334. package/src/utils/version-checker.js +4 -83
  335. package/framework/commands/morph-proposal.md +0 -57
  336. package/framework/evals/trace-schema.md +0 -67
  337. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  338. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  339. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  340. package/framework/hooks/shared/tasks-validator.js +0 -111
  341. package/framework/rules/morph-task-tracking.md +0 -54
  342. package/framework/rules/morph-workflow.md +0 -244
  343. package/framework/schemas/tasks.schema.json +0 -79
  344. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  345. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  346. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  347. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  348. package/framework/templates/docs/proposal.md +0 -192
  349. package/framework/templates/docs/spec.md +0 -57
  350. package/framework/templates/docs/ui-components.md +0 -52
  351. package/framework/templates/docs/ui-design-system.md +0 -76
  352. package/framework/templates/docs/ui-flows.md +0 -167
  353. package/framework/templates/examples/spec-examples.md +0 -91
  354. package/framework/templates/feature/decisions.md +0 -124
  355. package/framework/templates/feature/recap.md +0 -145
  356. package/framework/templates/tasks.template.json +0 -17
  357. package/src/commands/state/usecase.js +0 -143
  358. package/src/commands/tasks/dag.js +0 -181
  359. package/src/commands/utils/index.js +0 -3
  360. package/src/lib/agents/output-collector.js +0 -76
  361. package/src/lib/detectors/design-system-detector.js +0 -188
  362. package/src/lib/evaluator-report.js +0 -104
  363. package/src/lib/generators/design-system-generator.js +0 -508
  364. package/src/lib/specs/acervo-format.js +0 -70
  365. package/src/lib/specs/acervo-lifecycle.js +0 -264
  366. package/src/lib/specs/done-criteria-check.js +0 -200
  367. package/src/lib/specs/usecase-check.js +0 -301
  368. package/src/lib/specs/usecase-promoter.js +0 -301
  369. package/src/lib/specs/usecase-registry.js +0 -237
  370. package/src/lib/tasks/dispatch-planner.js +0 -319
  371. package/src/lib/tasks/dotnet-test-introspection.js +0 -632
  372. package/src/lib/tasks/mermaid-renderer.js +0 -132
  373. package/src/lib/tasks/task-classifier.js +0 -30
  374. package/src/lib/tasks/task-parser.js +0 -209
  375. package/src/lib/tasks/tasks-json-parser.js +0 -296
  376. package/src/lib/tasks/test-scope.js +0 -597
  377. package/src/utils/agent-id-mapper.js +0 -11
@@ -1,39 +1,3 @@
1
- /**
2
- * MORPH-SPEC Worktree Command Suite
3
- *
4
- * Isolates feature work into per-feature git worktrees at worktrees/{feature}/
5
- * on branch morph/{feature}, so each feature can run its own Claude Code
6
- * instance (v8 multi-instance, parallel-feature-development flow).
7
- *
8
- * `.morph/features/{f}/feature.json` is the authoritative, committed state — it
9
- * travels with the worktree's branch. `.morph/state.json` is a thin, gitignored,
10
- * derivable index; `worktree setup` MATERIALIZES a local one via loadState()'s
11
- * self-heal rather than linking to the main copy (a hard link breaks silently on
12
- * the first atomic write, since tmp+rename creates a new inode).
13
- *
14
- * Subcommands:
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
19
- * morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
20
- * morph-spec worktree remove [<feature>|--target <path>] [--if-clean] — safe teardown
21
- * morph-spec worktree list [--json] — list active feature worktrees
22
- *
23
- * Closing the lifecycle (integrate + teardown) lives in `morph-spec finish`
24
- * (src/commands/state/finish.js), which orchestrates archive → merge|PR →
25
- * teardown for BOTH the worktree and the common flow. This file exports the
26
- * hardened primitives it uses: `shedRootFeatureScope`, `primaryRootUnexplainedDirt`,
27
- * and `teardownWorktree` (PROVE-or-DECLINE removal).
28
- *
29
- * Exit codes (setup):
30
- * 0 — success (created) or fail-open (not a git repo, git error)
31
- * 1 — refused: feature name is not a plain kebab slug
32
- * 2 — worktree already exists at this exact path (signals resume to caller)
33
- * 3 — a live worktree for this feature already exists at a DIFFERENT path
34
- * (anti-dupe guard — refuses to fork a second live instance)
35
- */
36
-
37
1
  import { execSync, execFileSync } from 'child_process';
38
2
  import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatSync, unlinkSync, rmdirSync, cpSync, copyFileSync, readdirSync } from 'fs';
39
3
  import { join, resolve, dirname, basename } from 'path';
@@ -47,16 +11,12 @@ import {
47
11
  enforceBackupRetention,
48
12
  newBackupStamp,
49
13
  } from '../../lib/worktree/finish-backup.js';
50
- // The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
51
- // the SAME answer this file does, and hooks can never import from `src/`.
14
+ // Lives in hooks/shared: the PreToolUse guardrail needs the same answer and hooks cannot import `src/`.
52
15
  import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
53
16
  import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
54
17
  import { seedGraphFromRoot } from '../../../framework/hooks/shared/graph-refresh.js';
55
18
  import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
56
19
 
57
- // The container name lives in lib/worktree/dirs.js (single source, C1
58
- // rationale documented there); re-exported here so existing importers and the
59
- // dependency-matrix row keep pointing at this file.
60
20
  import { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
61
21
  import { applyEditorSettings } from '../../lib/worktree/vscode-settings.js';
62
22
  import { copyWorktreeIncludes } from '../../lib/worktree/worktree-include.js';
@@ -75,27 +35,7 @@ function rootAndEditorReport(root) {
75
35
  }
76
36
  export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES };
77
37
 
78
- // ── Pure helpers (exported for tests) ──────────────────────────────────────
79
-
80
- /**
81
- * Check if a directory is inside a git repository.
82
- * @param {string} cwd
83
- * @returns {boolean}
84
- */
85
- /**
86
- * Is `name` a plain kebab-case feature name?
87
- *
88
- * Mirrors the validation `morph-spec create` applies, and every worktree
89
- * subcommand enforces it for the same reason plus a sharper one: a feature name
90
- * flows into `worktrees/{name}`, the branch `morph/{name}`, and from there into
91
- * git invocations. Characters like `& | ; > ^` are legal in a git refname but
92
- * are cmd.exe metacharacters — on Windows an unvalidated name turns any
93
- * string-interpolated git call into command injection, and makes a `finish`
94
- * merge a DIFFERENT branch than the one it validated and reports.
95
- *
96
- * @param {string} name
97
- * @returns {boolean}
98
- */
38
+ /** Also guards git calls: `& | ; > ^` are legal in a refname but cmd.exe metacharacters on Windows. */
99
39
  export function isValidFeatureName(name) {
100
40
  return typeof name === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(name);
101
41
  }
@@ -109,12 +49,6 @@ export function isGitRepo(cwd = process.cwd()) {
109
49
  }
110
50
  }
111
51
 
112
- /**
113
- * Check if a branch exists locally.
114
- * @param {string} branch
115
- * @param {string} cwd
116
- * @returns {boolean}
117
- */
118
52
  export function branchExists(branch, cwd = process.cwd()) {
119
53
  try {
120
54
  execSync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: 'pipe' });
@@ -124,23 +58,6 @@ export function branchExists(branch, cwd = process.cwd()) {
124
58
  }
125
59
  }
126
60
 
127
- /**
128
- * Check if a worktree path is already registered with git.
129
- *
130
- * BUGFIX: the old `normalizedPath.startsWith('/')` absolute-path check only
131
- * recognized POSIX-style absolute paths. On Windows, `buildWorktreePath`
132
- * already returns an absolute `C:/...` path, which does NOT start with `/` —
133
- * so this used to re-prepend `cwd` onto an already-absolute path, producing
134
- * a bogus double-rooted string that never matched `git worktree list`'s
135
- * output. That silently broke the Gate 2 "already exists → resume" check on
136
- * Windows (it always fell through to a failing `git worktree add`, caught by
137
- * the outer fail-open handler). `path.resolve(cwd, worktreePath)` handles
138
- * both relative and platform-absolute paths correctly.
139
- *
140
- * @param {string} worktreePath Relative or absolute path
141
- * @param {string} cwd
142
- * @returns {boolean}
143
- */
144
61
  export function worktreeExists(worktreePath, cwd = process.cwd()) {
145
62
  try {
146
63
  const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
@@ -151,13 +68,7 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
151
68
  }
152
69
  }
153
70
 
154
- /**
155
- * Build the worktree directory path (always forward slashes).
156
- * @param {string} feature
157
- * @param {string} cwd
158
- * @param {{ fresh?: boolean }} options
159
- * @returns {string}
160
- */
71
+ /** Always forward slashes. */
161
72
  export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
162
73
  const suffix = options.fresh ? `-${getDateSuffix()}` : '';
163
74
  const normalizedCwd = cwd.replace(/\\/g, '/');
@@ -165,12 +76,6 @@ export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
165
76
  return `${container}/${feature}${suffix}`;
166
77
  }
167
78
 
168
- /**
169
- * Build the branch name for a feature worktree.
170
- * @param {string} feature
171
- * @param {{ fresh?: boolean }} options
172
- * @returns {string}
173
- */
174
79
  export function buildBranchName(feature, options = {}) {
175
80
  const suffix = options.fresh ? `-${getDateSuffix()}` : '';
176
81
  return `morph/${feature}${suffix}`;
@@ -180,24 +85,8 @@ function getDateSuffix() {
180
85
  return new Date().toISOString().slice(0, 10).replace(/-/g, '');
181
86
  }
182
87
 
183
- // ── Infrastructure linking ───────────────────────────────────────────────────
184
-
185
- /**
186
- * The infra directories morph itself links into a worktree — the ONLY ones
187
- * `setupInfraLinks`/`worktreeLinkCommand --all`/`unlinkWorktreeInfraLinks` treat
188
- * as KNOWN, and therefore the only ones `finish` may auto-remove via the
189
- * already-proven-safe unlink mechanism (never by widening the reparse-point
190
- * scan — see `findReparsePoints`).
191
- *
192
- * Always: `node_modules`, `.claude`. Conditionally: `.morph/framework` (client
193
- * projects only) and `{project.frontendPath}/node_modules` — a monorepo
194
- * frontend (e.g. a Next.js app under `apps/catalog-admin`) commonly has its OWN
195
- * node_modules, a separate `npm install` from the root's, invisible to a static
196
- * list. Fail-open: a missing/unreadable config.json just means no frontendPath.
197
- *
198
- * @param {string} mainCwd - Absolute path to the main project root
199
- * @returns {string[]} relative paths, root-first
200
- */
88
+ // The ONLY links `finish` may auto-remove (never widened to any reparse point found), plus
89
+ // `{project.frontendPath}/node_modules`: a monorepo frontend has its own install.
201
90
  function resolveInfraLinkDirs(mainCwd) {
202
91
  const dirs = ['node_modules', '.claude'];
203
92
  if (existsSync(join(mainCwd, '.morph', 'framework'))) {
@@ -210,29 +99,13 @@ function resolveInfraLinkDirs(mainCwd) {
210
99
  const frontendPath = config?.project?.frontendPath;
211
100
  if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
212
101
  }
213
- } catch { /* fail-open — no frontendPath to add */ }
102
+ } catch {}
214
103
  return dirs;
215
104
  }
216
105
 
217
106
  /**
218
- * Create junctions in the worktree for shared infrastructure directories
219
- * (node_modules/, .claude/, .morph/framework/).
220
- *
221
- * `.morph/state.json` is deliberately NEVER linked here — a hard link breaks
222
- * silently the moment either side writes atomically (tmp file + rename creates
223
- * a fresh inode, severing the link), so worktree and main state can silently
224
- * diverge. The worktree's local state.json is MATERIALIZED instead, see
225
- * `materializeState()`. `memory/` and `logs/` are also never linked — each
226
- * worktree instance owns its own debounce/session snapshots.
227
- *
228
- * Uses junctions for directories (no admin required on Windows).
229
- * Fail-open: any error is silently ignored.
230
- *
231
- * @param {string} worktreePath - Absolute path to the worktree
232
- * @param {string} mainCwd - Absolute path to the main project root
233
- * @param {{ dirs?: string[] }} [opts] - When `dirs` is provided, restricts
234
- * which junctions are (re)created to exactly that list. Default: node_modules,
235
- * .claude, and .morph/framework (the last only if it exists in the main root).
107
+ * `.morph/state.json` is never linked: an atomic write (tmp + rename) makes a new inode and silently
108
+ * severs a hard link, so the worktree materializes its own (`materializeState`).
236
109
  */
237
110
  export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
238
111
  const mainAbs = resolve(mainCwd);
@@ -250,14 +123,11 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
250
123
  const parent = dirname(linkPath);
251
124
  if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
252
125
  symlinkSync(target, linkPath, 'junction');
253
- } catch { /* fail-open */ }
126
+ } catch {}
254
127
  }
255
128
 
256
- // Only the reparse points: a REAL directory at one of these paths was not
257
- // created by us, is not a link out of the worktree, and git's normal rules
258
- // already speak for it. `lstat` reports a POSIX symlink and a Windows
259
- // junction alike as a symbolic link, so the exclude lands identically on
260
- // both platforms — as does the bug it prevents (see excludeInfraLinks).
129
+ // Only reparse points: `lstat` reports a POSIX symlink and a Windows junction alike, so the
130
+ // exclude lands identically on both platforms.
261
131
  excludeWorktreePaths(mainAbs, dirLinks.filter((rel) => {
262
132
  try {
263
133
  return lstatSync(join(wtAbs, rel)).isSymbolicLink();
@@ -271,47 +141,9 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
271
141
  const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed — see setupInfraLinks)';
272
142
 
273
143
  /**
274
- * Teach the repo's git to ignore worktree-carried paths morph itself creates —
275
- * the infra links from `setupInfraLinks` and the fleet env manifest
276
- * (`.morph/worktree.env`). Same mechanism, same reasons, for both.
277
- *
278
- * CRITICAL — without this, `morph-spec finish` (worktree teardown) is unusable on
279
- * POSIX and destructive at worst, because git never sees a link as a link: it sees what
280
- * the OS reports. A Windows junction IS a directory, so a `.morph/framework/`
281
- * rule (trailing slash = directories only) matches it and git ignores it. A
282
- * POSIX symlink is a FILE (mode 120000), so the very same rule does NOT match
283
- * and the link surfaces as untracked (`??`). Both endings are bad:
284
- * - `finish` refuses on its clean-worktree gate (`worktree-dirty`) — the
285
- * careful user who commits only real work can never finish at all; or
286
- * - a `git add -A` COMMITS the links onto the feature branch, and the merge
287
- * then replaces the primary root's real `.claude/` and `.morph/framework/`
288
- * with symlinks pointing at themselves. The installation is gone, and
289
- * `finished: true` is reported.
290
- *
291
- * The user's `.gitignore` cannot be the fix: it is a TRACKED file (rewriting it
292
- * dirties the very tree these gates read), morph deliberately ignores only the
293
- * morph-OWNED subpaths of `.claude/` so a user's own settings stay trackable,
294
- * and existing installs would only pick up a new rule on a re-init.
295
- * `.git/info/exclude` is git's own answer to "ignore this locally": never
296
- * committed, additive (it does not override the user's global excludes, and an
297
- * exclude NEVER applies to a tracked file), and — unlike the per-worktree
298
- * `.git/worktrees/<id>/info/exclude`, which git does not read for a linked
299
- * worktree — honored from the COMMON git dir by every worktree of the repo.
300
- *
301
- * TRADEOFF, deliberate: patterns are matched against each working tree's own
302
- * top and the file is shared, so `/.claude` also hides an UNTRACKED `.claude/`
303
- * in the primary root. gitignore cannot express "only when it is a symlink",
304
- * and no negation can re-include a path under an excluded directory. The cost
305
- * is narrow — tracked files are untouched, and a team that versions
306
- * `.claude/settings.json` has it tracked — and it buys the links being
307
- * invisible to `git add -A`, which is what keeps the merge from eating the
308
- * installation.
309
- *
310
- * Idempotent (never duplicates a pattern) and fail-open: an exclude we could
311
- * not write costs a refused finish, never a wrong one.
312
- *
313
- * @param {string} mainCwd - Absolute path to the main project root
314
- * @param {string[]} rels - worktree-relative paths to exclude
144
+ * Git sees a POSIX symlink as a FILE, so a dirs-only rule misses it and `git add -A` commits the links
145
+ * (the merge then eats the root's installation). The COMMON dir's `info/exclude` is honored by every
146
+ * worktree; the accepted cost is that it also hides an untracked `.claude/` in the root.
315
147
  */
316
148
  function excludeWorktreePaths(mainCwd, rels) {
317
149
  // Anchored (`/x`): these live at the top of a worktree, and an unanchored
@@ -319,16 +151,7 @@ function excludeWorktreePaths(mainCwd, rels) {
319
151
  ensureCommonExclude(mainCwd, rels.map((rel) => `/${rel.replace(/\\/g, '/')}`), EXCLUDE_MARKER);
320
152
  }
321
153
 
322
- /**
323
- * Resolve the absolute path (forward slashes) of the primary/main git worktree,
324
- * from anywhere inside the repo (including from within a secondary worktree).
325
- *
326
- * Fail-open: if `cwd` is not a git repo (or the git call errors), returns
327
- * `cwd` itself, resolved and normalized.
328
- *
329
- * @param {string} cwd
330
- * @returns {string} Absolute, forward-slash path to the primary worktree
331
- */
154
+ /** Fail-open: outside a git repo (or on a git error) returns `cwd` itself, normalized. */
332
155
  export function resolvePrimaryRoot(cwd = process.cwd()) {
333
156
  try {
334
157
  const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
@@ -336,39 +159,13 @@ export function resolvePrimaryRoot(cwd = process.cwd()) {
336
159
  if (line) {
337
160
  return line.slice('worktree '.length).trim().replace(/\\/g, '/');
338
161
  }
339
- } catch { /* fall through to fail-open */ }
162
+ } catch {}
340
163
  return resolve(cwd).replace(/\\/g, '/');
341
164
  }
342
165
 
343
166
  /**
344
- * Materialize a LOCAL, real `.morph/state.json` inside a worktree, reconstructed
345
- * from the committed per-feature `feature.json` files under `.morph/features/`
346
- * — not linked to the main copy (see `setupInfraLinks` doc for why hard links
347
- * are unsafe here).
348
- *
349
- * Mechanism: temporarily chdir into the worktree and call `loadState(false)`,
350
- * whose self-heal path (state-manager.js) scaffolds + hydrates + writes a fresh
351
- * root index when `.morph/state.json` is missing but per-feature files exist.
352
- * `process.chdir` is process-global, so the original cwd is ALWAYS restored via
353
- * finally, even on error. Fail-open: never throws.
354
- *
355
- * @param {string} worktreePath - Absolute path to the worktree
356
- * @returns {Promise<{ materialized: boolean, reason?: string }>}
357
- */
358
- /**
359
- * Guarantee the worktree owns a local `.morph/state.json`, hydrated from its
360
- * committed features when there are any, and an EMPTY index otherwise.
361
- *
362
- * Why the empty index matters: every core hook fail-opens on `stateExists()`,
363
- * which is true only with a `state.json` or a feature dir in the tree. A
364
- * task-isolation worktree of a project with no committed features (measured
365
- * in the sandbox e2e) had neither — so the env-inject hook, the removal guard
366
- * and every other guardrail were silently dark inside it. `state.json` is
367
- * gitignored and derivable, so an empty one costs nothing and can never dirty
368
- * the tree the finish gates read.
369
- *
370
- * @param {string} worktreePath
371
- * @returns {Promise<{ materialized: boolean, reason?: string, seeded?: boolean }>}
167
+ * Writes an EMPTY index when there is nothing to hydrate: every hook fail-opens on `stateExists()`,
168
+ * and a tree with neither state.json nor features left them all silently dark.
372
169
  */
373
170
  export async function ensureLocalState(worktreePath) {
374
171
  const result = await materializeState(worktreePath);
@@ -402,12 +199,8 @@ export async function materializeState(worktreePath) {
402
199
  try {
403
200
  process.chdir(resolve(worktreePath));
404
201
  const { loadState } = await import('../../core/state/state-manager.js');
405
- loadState(false); // self-heal: writes a fresh local .morph/state.json
406
- // loadState(false) only writes state.json when it actually found something
407
- // to hydrate (a non-empty .morph/features/). A present-but-empty features
408
- // dir (e.g. right after `archive` moved the last feature out) makes
409
- // loadState return null and write nothing — don't report success in that
410
- // case, or callers believe a local state.json exists when it doesn't.
202
+ loadState(false);
203
+ // loadState(false) writes nothing for a present-but-empty features dir: don't report success then.
411
204
  if (existsSync(join(worktreePath, '.morph', 'state.json'))) {
412
205
  return { materialized: true };
413
206
  }
@@ -419,20 +212,8 @@ export async function materializeState(worktreePath) {
419
212
  }
420
213
  }
421
214
 
422
- /**
423
- * Codebase graph for a new worktree. The tree is born from a root commit, so
424
- * the root's graph is either this tree's HEAD graph already or one
425
- * `morph-spec graph refresh` away — and the `reuse` validator reads the graph
426
- * of the tree `verify` runs in, so a worktree without one skips reuse checking
427
- * exactly where the new code is written. Nothing is launched in the
428
- * background: the automatic refresh only ever touches the root (a detached
429
- * process holding a worktree directory made `finish` fail on win32).
430
- * Fail-open: no root graph, no copy.
431
- *
432
- * @param {string} worktreePath
433
- * @param {string} root
434
- * @returns {{seeded: boolean, reason?: string}}
435
- */
215
+ // Nothing launched in the background: a detached process holding a worktree directory made
216
+ // `finish` fail on win32. Fail-open: no root graph, no copy.
436
217
  function seedWorktreeGraph(worktreePath, root) {
437
218
  try {
438
219
  return seedGraphFromRoot(worktreePath, root);
@@ -441,35 +222,18 @@ function seedWorktreeGraph(worktreePath, root) {
441
222
  }
442
223
  }
443
224
 
444
- // ── Command handler ─────────────────────────────────────────────────────────
445
-
446
225
  /**
447
- * Non-exiting core of `worktree setup` — does all the real work (git checks,
448
- * guards, branch creation, `git worktree add`, infra linking, state
449
- * materialization, metadata persistence) and returns a result object instead
450
- * of calling `process.exit`/`console.log`, so callers (e.g. a future
451
- * `create --worktree` that needs to chain further steps) can reuse it in
452
- * process. `worktreeSetupCommand` below is a thin CLI wrapper around this.
453
- *
454
- * @param {string} feature
455
- * @param {{ fresh?: boolean, cwd?: string }} [options]
456
- * @returns {Promise<{ ok: boolean, exitCode: 0|1|2|3, created?: boolean, alreadyExists?: boolean, path?: string, branch?: string, feature: string, materialized?: boolean, nextStep?: string, error?: string, existingPath?: string }>}
457
- * exitCode 1 = refused, `error: 'invalid-feature-name'`. This function is the
458
- * non-exiting core precisely so callers can reuse it in-process — a caller
459
- * trusting a `0|2|3` union would mishandle that refusal.
226
+ * Non-exiting core of `worktree setup`. exitCode 1 = refused (`invalid-feature-name`),
227
+ * 2 = already exists here, 3 = a live worktree for the feature exists elsewhere.
460
228
  */
461
229
  export async function runWorktreeSetup(feature, options = {}) {
462
230
  const cwd = options.cwd || process.cwd();
463
231
 
464
- // Gate 0: the name must be a plain kebab slug before it reaches a path, a
465
- // refname, or any git call. See isValidFeatureName — `create` already refuses
466
- // these names, so accepting them here would only ever create a worktree no
467
- // other morph-spec command can address.
232
+ // A plain kebab slug before it reaches a path, a refname or any git call (see isValidFeatureName).
468
233
  if (!isValidFeatureName(feature)) {
469
234
  return { ok: false, exitCode: 1, created: false, error: 'invalid-feature-name', feature };
470
235
  }
471
236
 
472
- // Gate 1: Must be a git repo
473
237
  if (!isGitRepo(cwd)) {
474
238
  return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', feature };
475
239
  }
@@ -477,16 +241,11 @@ export async function runWorktreeSetup(feature, options = {}) {
477
241
  const branch = buildBranchName(feature, options);
478
242
  const worktreePath = buildWorktreePath(feature, cwd, options);
479
243
 
480
- // Gate 2: If worktree already registered and not --fresh, signal resume
481
244
  if (!options.fresh && worktreeExists(worktreePath, cwd)) {
482
245
  return { ok: false, exitCode: 2, created: false, alreadyExists: true, path: worktreePath, branch, feature };
483
246
  }
484
247
 
485
- // Guard 3: anti-dupe — refuse to fork a second LIVE worktree for the same
486
- // feature. If feature.json already records a worktree path that still exists
487
- // on disk and differs from the one this call would create, stop (exit 3).
488
- // (A recorded path that's gone from disk — e.g. removed via `git worktree
489
- // remove` — does not block: existsSync() is false, so this falls through.)
248
+ // Anti-dupe: refuse a second LIVE worktree for this feature; a recorded path gone from disk does not block.
490
249
  try {
491
250
  const featureJsonPath = join(cwd, '.morph', 'features', feature, 'feature.json');
492
251
  if (existsSync(featureJsonPath)) {
@@ -510,15 +269,8 @@ export async function runWorktreeSetup(feature, options = {}) {
510
269
 
511
270
  let branchedFrom = null;
512
271
  try {
513
- // Create branch if needed — off the DEFAULT branch, never the current HEAD.
514
- // Branching from HEAD meant a root parked on some other `morph/{other}`
515
- // gave the new feature that feature's commits, and every PR opened from
516
- // this worktree carried the sibling's diff (A1). Symmetric with the common
517
- // flow in create.js → branchCommonFlow.
518
- //
519
- // Fail-open, same as create: with no resolvable default (a repo whose
520
- // default is neither `main`/`master` nor declared via origin/HEAD) the
521
- // branch is still created — off HEAD — and `branchedFrom: null` reports it.
272
+ // Off the DEFAULT branch, never HEAD: a root parked on another `morph/*` would hand over its commits.
273
+ // No resolvable default → off HEAD, reported as `branchedFrom: null`.
522
274
  if (!branchExists(branch, cwd)) {
523
275
  const defaultBranch = detectDefaultBranch(cwd);
524
276
  if (defaultBranch) {
@@ -529,48 +281,32 @@ export async function runWorktreeSetup(feature, options = {}) {
529
281
  }
530
282
  }
531
283
 
532
- // Create worktree directory parent if needed
533
284
  const parentDir = newWorktreesDir(cwd);
534
285
  if (!existsSync(parentDir)) {
535
286
  mkdirSync(parentDir, { recursive: true });
536
287
  }
537
288
 
538
- // Add worktree
539
289
  execSync(`git worktree add "${worktreePath}" ${branch}`, { cwd, stdio: 'pipe' });
540
290
 
541
- // Set up infrastructure junctions (node_modules, .claude, .morph/framework).
542
291
  setupInfraLinks(worktreePath, cwd);
543
292
  const included = copyWorktreeIncludes(worktreePath, cwd);
544
293
 
545
- // Fleet env: allocate this feature's deterministic port block and write the
546
- // worktree-local manifest (.morph/worktree.env). Fail-open — a worktree
547
- // without ports is degraded, never broken.
294
+ // Fail-open: a worktree without ports is degraded, never broken.
548
295
  const envBlock = await allocateWorktreeEnv(worktreePath, cwd, feature);
549
296
 
550
- // Materialize a local, real state.json in the worktree (self-heal from the
551
- // committed feature.json files) so hooks work from the very first session.
552
297
  const materialized = await ensureLocalState(worktreePath);
553
298
 
554
299
  const graph = seedWorktreeGraph(worktreePath, cwd);
555
300
 
556
- // Fleet handoff: the fresh instance that opens this worktree reads
557
- // handoff.md before anything else (SessionStart hook points at it). The doc
558
- // is derived and git-invisible (see handoff-generator). Fail-open — and a
559
- // no-op in the create --worktree flow, where the feature dir only lands in
560
- // the worktree after create's copy (create regenerates it then).
301
+ // Fail-open; a no-op under `create --worktree`, which regenerates it after its copy.
561
302
  try {
562
303
  if (existsSync(join(worktreePath, '.morph', 'features', feature, 'feature.json'))) {
563
304
  const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
564
- // `worktree` is ROOT-OWNED bookkeeping, absent from this branch-local
565
- // copy by contract — pass it explicitly so the doc built FROM INSIDE
566
- // the worktree still says "Local: worktree", not "raiz primária".
305
+ // `worktree` is root-owned and absent from this branch copy: pass it so the doc says "Local: worktree".
567
306
  writeHandoff(feature, worktreePath, { worktree: { path: worktreePath, branch } });
568
307
  }
569
- } catch { /* fail-open */ }
308
+ } catch {}
570
309
 
571
- // Persist in state (non-blocking — feature may not be in state yet).
572
- // NOTE: imports from core/ (the lib/ path was a latent bug that silently
573
- // no-op'd this whole block); loadState/saveState operate on the cwd's state.
574
310
  try {
575
311
  const { loadState, saveState } = await import('../../core/state/state-manager.js');
576
312
  const state = loadState(false);
@@ -579,7 +315,6 @@ export async function runWorktreeSetup(feature, options = {}) {
579
315
  saveState(state);
580
316
  }
581
317
  } catch {
582
- // Non-blocking
583
318
  }
584
319
 
585
320
  return {
@@ -602,31 +337,13 @@ export async function runWorktreeSetup(feature, options = {}) {
602
337
  }
603
338
  }
604
339
 
605
- /**
606
- * Main command handler for `morph-spec worktree setup <feature>` — thin
607
- * wrapper around `runWorktreeSetup` that prints its JSON result and exits
608
- * with its exit code. Preserves the exact JSON shape and exit codes (0/2/3)
609
- * this command has always used.
610
- * @param {string} feature
611
- * @param {{ fresh?: boolean, cwd?: string }} options
612
- */
613
340
  export async function worktreeSetupCommand(feature, options = {}) {
614
341
  const { ok, exitCode, ...result } = await runWorktreeSetup(feature, options);
615
342
  console.log(JSON.stringify(result));
616
343
  process.exit(exitCode);
617
344
  }
618
345
 
619
- // ── provision (task isolation — a worktree that is NOT a feature) ──────────
620
-
621
- /**
622
- * Branch name for a task-isolation worktree: `morph-task/<name>`. A separate
623
- * namespace from `morph/<feature>` on purpose — `worktree list`, `fleet`,
624
- * `finish` and the SessionStart hook all read `morph/<f>` as "this IS feature
625
- * f", and a scratch tree named after a feature must never impersonate it.
626
- *
627
- * @param {string} name
628
- * @returns {string}
629
- */
346
+ /** A separate namespace on purpose: a scratch tree must never impersonate `morph/<feature>`. */
630
347
  export function buildTaskBranchName(name) {
631
348
  return `morph-task/${name}`;
632
349
  }
@@ -634,18 +351,8 @@ export function buildTaskBranchName(name) {
634
351
  const ROOT_ENV_FILE_RE = /^\.env(\..+)?$/;
635
352
 
636
353
  /**
637
- * Copy the root's `.env` / `.env.*` into a new tree: a sub-agent that builds
638
- * or runs the app needs the same secrets, and they are never committed. Only
639
- * a REAL file (never a link — nothing in the tree may reach the root through
640
- * one), only when the tree lacks it (never overwrite), and only when git
641
- * IGNORES it there: an untracked-but-visible copy would dirty the tree, and
642
- * `finish`/`worktree remove --if-clean` refuse a dirty tree. At teardown the
643
- * copy is byte-identical to the root's, which `triageIgnoredPaths` classifies
644
- * as REPLICABLE — deleted with nothing lost.
645
- *
646
- * @param {string} worktreePath
647
- * @param {string} root
648
- * @returns {string[]} names copied
354
+ * Only a REAL file, never overwriting, and only when git IGNORES it in the tree: a visible copy
355
+ * would dirty it and `finish` refuses a dirty tree. At teardown the copy is a replica of the root's.
649
356
  */
650
357
  export function copyRootEnvFiles(worktreePath, root) {
651
358
  let names;
@@ -659,32 +366,16 @@ export function copyRootEnvFiles(worktreePath, root) {
659
366
  const dst = join(worktreePath, name);
660
367
  try {
661
368
  if (!lstatSync(join(root, name)).isFile()) continue;
662
- try { lstatSync(dst); continue; } catch { /* absent — not even a dangling link */ }
369
+ try { lstatSync(dst); continue; } catch {}
663
370
  execFileSync('git', ['check-ignore', '-q', '--', name], { cwd: worktreePath, stdio: 'pipe' });
664
371
  copyFileSync(join(root, name), dst);
665
372
  copied.push(name);
666
- } catch { /* not ignored, unreadable or unwritable — leave it out */ }
373
+ } catch {}
667
374
  }
668
375
  return copied;
669
376
  }
670
377
 
671
- /**
672
- * Non-exiting core of `worktree provision` — the tree `claude -w`,
673
- * `EnterWorktree` and `Agent isolation:"worktree"` ask for when the name is
674
- * not a feature. Everything `setup` gives a feature worktree EXCEPT the
675
- * feature identity: it lands under `<primary root>/worktrees/<name>` (never
676
- * nested under a secondary worktree, whatever the cwd), on `morph-task/<name>`
677
- * off the default branch (or `--base`), with the infra junctions, a git-
678
- * invisible `.morph/worktree.env` keyed `task/<name>`, and a materialized
679
- * local state.json. No feature registration, no handoff.
680
- *
681
- * Exit codes mirror `setup`: 0 created (or fail-open), 1 refused (bad name),
682
- * 2 already exists at this exact path.
683
- *
684
- * @param {string} name
685
- * @param {{ base?: string, cwd?: string }} [options]
686
- * @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 }>}
687
- */
378
+ /** Non-exiting core of `worktree provision`: `setup` without the feature identity. Exit codes mirror `setup`. */
688
379
  export async function runWorktreeProvision(name, options = {}) {
689
380
  const cwd = options.cwd || process.cwd();
690
381
 
@@ -695,9 +386,7 @@ export async function runWorktreeProvision(name, options = {}) {
695
386
  return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', name };
696
387
  }
697
388
 
698
- // Always the PRIMARY root: a provision issued from inside a feature worktree
699
- // must not nest a worktree under it — that tree would be invisible to the
700
- // fleet and to doctor, which enumerate `<root>/worktrees/`.
389
+ // Always the PRIMARY root: a tree nested under a feature worktree is invisible to fleet and doctor.
701
390
  const root = resolvePrimaryRoot(cwd);
702
391
  const branch = buildTaskBranchName(name);
703
392
  const worktreePath = buildWorktreePath(name, root);
@@ -755,34 +444,17 @@ export async function runWorktreeProvision(name, options = {}) {
755
444
  }
756
445
  }
757
446
 
758
- /**
759
- * `morph-spec worktree provision <name> [--base <ref>]` — thin wrapper around
760
- * `runWorktreeProvision`, same JSON-and-exit contract as `setup`.
761
- * @param {string} name
762
- * @param {{ base?: string, cwd?: string }} [options]
763
- */
764
447
  export async function worktreeProvisionCommand(name, options = {}) {
765
448
  const { ok, exitCode, ...result } = await runWorktreeProvision(name, options);
766
449
  console.log(JSON.stringify(result));
767
450
  process.exit(exitCode);
768
451
  }
769
452
 
770
- /**
771
- * Command handler for `morph-spec worktree link [--target <path>] [--all]`.
772
- *
773
- * (Re)links shared infrastructure (node_modules by default; also .claude and
774
- * .morph/framework with --all) from the primary root into a worktree. Useful
775
- * to repair/refresh junctions without recreating the whole worktree.
776
- * Idempotent — already-present junctions are reported as skipped, not re-created.
777
- *
778
- * @param {{ target?: string, all?: boolean, cwd?: string }} [options]
779
- */
780
453
  export async function worktreeLinkCommand(options = {}) {
781
454
  const cwdArg = options.cwd || process.cwd();
782
455
  const root = resolvePrimaryRoot(cwdArg);
783
456
  const target = resolve(options.target || options.cwd || process.cwd()).replace(/\\/g, '/');
784
457
 
785
- // No-op: linking the primary root into itself makes no sense.
786
458
  if (target === root) {
787
459
  console.log(JSON.stringify({ linked: [], skipped: ['is-primary-root'], root, target }));
788
460
  process.exit(0);
@@ -790,7 +462,7 @@ export async function worktreeLinkCommand(options = {}) {
790
462
 
791
463
  if (!isGitRepo(cwdArg)) {
792
464
  console.log(JSON.stringify({ error: 'not-a-git-repo' }));
793
- process.exit(0); // Fail-open
465
+ process.exit(0);
794
466
  }
795
467
 
796
468
  const dirs = options.all
@@ -808,18 +480,15 @@ export async function worktreeLinkCommand(options = {}) {
808
480
  const skipped = [];
809
481
  for (const rel of dirs) {
810
482
  if (alreadyPresent[rel]) {
811
- skipped.push(rel); // already there before this call
483
+ skipped.push(rel);
812
484
  } else if (existsSync(join(target, rel))) {
813
- linked.push(rel); // newly created by setupInfraLinks
485
+ linked.push(rel);
814
486
  } else {
815
- skipped.push(rel); // nothing to link (source missing in root, or link failed)
487
+ skipped.push(rel);
816
488
  }
817
489
  }
818
490
 
819
- // `link --all` is the documented repair for a worktree missing its infra —
820
- // that includes a `.morph/worktree.env` written by an older layout. The
821
- // allocation is idempotent (same key → same block), so regenerating the
822
- // manifest never moves a worktree's ports; it only adds the keys it lacks.
491
+ // Allocation is idempotent (same key → same block): regenerating never moves a worktree's ports.
823
492
  let env = null;
824
493
  if (options.all) {
825
494
  const key = envKeyForWorktree(target, root);
@@ -831,15 +500,7 @@ export async function worktreeLinkCommand(options = {}) {
831
500
  process.exit(0);
832
501
  }
833
502
 
834
- /**
835
- * The env-registry key of a worktree, derived from its branch: `morph/<f>` →
836
- * `<f>` (feature), `morph-task/<n>` → `task/<n>` (task isolation). `null` for
837
- * a branch morph does not own — no block is allocated for those.
838
- *
839
- * @param {string} worktreePath
840
- * @param {string} root
841
- * @returns {string|null}
842
- */
503
+ /** `morph/<f>` → `<f>`, `morph-task/<n>` → `task/<n>`, `null` for a branch morph does not own. */
843
504
  export function envKeyForWorktree(worktreePath, root) {
844
505
  const norm = resolve(worktreePath).replace(/\\/g, '/');
845
506
  const entry = parseWorktreeList(root).find((e) => e.path === norm);
@@ -852,26 +513,8 @@ export function envKeyForWorktree(worktreePath, root) {
852
513
  }
853
514
 
854
515
  /**
855
- * Allocate (idempotently) the port block for `envKey` and write the worktree's
856
- * `.morph/worktree.env`, excluded from git via the same common-dir mechanism as
857
- * the infra links (it must be invisible to the finish clean-checks). Under
858
- * `.morph/` on purpose: that prefix is already shieldable at finish (backed up,
859
- * never a removal blocker). Fail-open: returns `null` when no block could be
860
- * written — a worktree without ports is degraded, never broken.
861
- *
862
- * Shared by `setup` (feature key), `provision` (task key) and `link --all`
863
- * (regeneration), so the manifest has exactly one writer.
864
- *
865
- * `morphFeature` (task trees born from `morph/<f>`) only renames the
866
- * `MORPH_FEATURE` line. Left undefined — the `link --all` regeneration, which
867
- * derives the key from the branch and cannot see the base — the value the
868
- * file already carries is kept, so a repair never forgets the feature.
869
- *
870
- * @param {string} worktreePath
871
- * @param {string} root - primary root
872
- * @param {string} envKey - `<feature>` or `task/<name>`
873
- * @param {{ morphFeature?: string|null }} [opts]
874
- * @returns {Promise<{ index: number, base: number, range: [number, number] }|null>}
516
+ * The manifest's single writer. Without `morphFeature` (the `link --all` repair) the MORPH_FEATURE
517
+ * already in the file is kept. `null` when no block could be written.
875
518
  */
876
519
  export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFeature } = {}) {
877
520
  const envPath = join(worktreePath, '.morph', 'worktree.env');
@@ -894,7 +537,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
894
537
  const cfg = JSON.parse(readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
895
538
  stack = cfg?.project?.stack || '';
896
539
  aliases = resolvePortAliases(cfg).aliases;
897
- } catch { /* fail-open — stackless env still carries PORT */ }
540
+ } catch {}
898
541
  mkdirSync(join(worktreePath, '.morph'), { recursive: true });
899
542
  writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature, aliases }), 'utf8');
900
543
  excludeWorktreePaths(root, [join('.morph', 'worktree.env')]);
@@ -904,14 +547,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
904
547
  }
905
548
  }
906
549
 
907
- // ── finish / list shared helpers ────────────────────────────────────────────
908
-
909
- /**
910
- * Parse `git worktree list --porcelain` into structured entries.
911
- * Fail-open: returns `[]` on any git error (e.g. not a git repo).
912
- * @param {string} cwd
913
- * @returns {Array<{ path: string, branch: string|null, head: string|null, locked: boolean }>}
914
- */
550
+ /** Fail-open: `[]` on any git error. */
915
551
  export function parseWorktreeList(cwd) {
916
552
  let output;
917
553
  try {
@@ -939,22 +575,8 @@ export function parseWorktreeList(cwd) {
939
575
  }
940
576
 
941
577
  /**
942
- * Locate the registered worktree for a feature's `morph/{feature}` branch.
943
- * Primary strategy: match the `branch refs/heads/morph/{feature}` entry from
944
- * `git worktree list --porcelain`. Fallback: the conventional
945
- * `buildWorktreePath` location, but only if it is itself a registered
946
- * worktree entry (so a stray directory never gets mistaken for a live one).
947
- *
948
- * Returns the branch the worktree is ACTUALLY on, never the computed one. Via
949
- * the path fallback the two can diverge (the user checked out something else in
950
- * there), and reporting the computed name would make `finish` merge
951
- * `morph/{feature}` while removing a worktree holding unrelated work — merging a
952
- * branch it never validated, which is the whole failure this command guards
953
- * against. The caller compares the two and refuses on mismatch.
954
- *
955
- * @param {string} feature
956
- * @param {string} root - Primary root path
957
- * @returns {{ path: string, branch: string, expectedBranch: string }|null}
578
+ * Returns the branch the worktree is ACTUALLY on, never the computed one: via the path fallback they
579
+ * can diverge, and the caller refuses on mismatch rather than merge a branch it never validated.
958
580
  */
959
581
  export function findWorktreeForFeature(feature, root) {
960
582
  const expectedBranch = buildBranchName(feature);
@@ -968,41 +590,13 @@ export function findWorktreeForFeature(feature, root) {
968
590
  }
969
591
  if (!match) return null;
970
592
 
971
- // A detached HEAD has no branch at all — report it as such rather than
972
- // inventing the computed name for it.
973
593
  const actual = match.branch ? match.branch.replace(/^refs\/heads\//, '') : null;
974
594
  return { path: match.path, branch: actual, expectedBranch };
975
595
  }
976
596
 
977
597
  /**
978
- * Drop the infra junctions a worktree carries (`node_modules`, `.claude`,
979
- * `.morph/framework`, and the `project.frontendPath` node_modules if
980
- * configured) WITHOUT following them.
981
- *
982
- * CRITICAL — this is what keeps `morph-spec finish` (worktree teardown) from
983
- * destroying the project. `git worktree remove` refuses a worktree still holding these ("contains
984
- * modified or untracked files"), and the `--force` fallback FOLLOWS the
985
- * junctions and deletes the PRIMARY ROOT's real `.claude/` and
986
- * `.morph/framework/` — wiping the morph-spec installation (MORPH.md, agents,
987
- * skills, settings) while still reporting `finished: true`. On a short path the
988
- * wipe is total and silent; on a long one MAX_PATH aborts it midway, leaving
989
- * partial damage plus an orphan directory. Removing the reparse points first
990
- * lets the plain (non-force) removal succeed and leaves `--force` nothing to
991
- * traverse.
992
- *
993
- * Only links are touched: `unlinkSync`/`rmdirSync` act on the LINK, never on the
994
- * target's contents, and `rmdirSync` fails on a real non-empty directory — the
995
- * safety that stops this from ever deleting genuine files.
996
- *
997
- * This is deliberately narrow to the KNOWN inventory from `resolveInfraLinkDirs`
998
- * — never widened to auto-remove any reparse point `findReparsePoints` might
999
- * discover elsewhere. An unknown/nested link (npm link, npm workspaces) must
1000
- * still decline the removal; see the CANARY tests pinning that in worktree.test.js.
1001
- *
1002
- * @param {string} worktreePath
1003
- * @param {string} mainCwd - Absolute path to the main project root, to resolve
1004
- * `project.frontendPath` from config.json
1005
- * @returns {string[]} relative paths actually unlinked
598
+ * Must run before `git worktree remove`: its `--force` fallback FOLLOWS junctions and deletes the root's
599
+ * real `.claude/` and `.morph/framework/`. Only the known inventory, never any reparse point found.
1006
600
  */
1007
601
  export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
1008
602
  const removed = [];
@@ -1012,7 +606,7 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
1012
606
  try {
1013
607
  st = lstatSync(p);
1014
608
  } catch {
1015
- continue; // absent — nothing to drop
609
+ continue;
1016
610
  }
1017
611
  if (!st.isSymbolicLink() && !st.isDirectory()) continue;
1018
612
  try {
@@ -1028,36 +622,10 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
1028
622
  return removed;
1029
623
  }
1030
624
 
1031
- /**
1032
- * Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
1033
- * once backed up (see writeWorktreeIgnoredBackup). `.morph/state.json` is provably
1034
- * derivable (loadState self-heals it from the committed feature.json files) and
1035
- * the rest is morph's own diagnostic output, which `setupInfraLinks` documents
1036
- * as worktree-local. They are small, so backing them up costs nothing and buys
1037
- * the guarantee that this list does not have to be RIGHT.
1038
- *
1039
- * `node_modules` is deliberately NOT here: it is regenerable from the lockfile,
1040
- * far too large to copy, and `unlinkWorktreeInfraLinks` already dropped it when
1041
- * it is the junction `setup` creates. A real one left behind blocks removal —
1042
- * safe, and the user can clear it themselves.
1043
- */
625
+ // Safe to remove only once backed up. `node_modules` is not here: regenerable and too large to copy.
1044
626
  const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
1045
627
 
1046
- /**
1047
- * Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
1048
- * registration — see the call site (primaryRootUnexplainedDirt, the root-clean
1049
- * gate `morph-spec finish` uses) for why this is safe to exempt from the
1050
- * whole-root clean gate rather than a widened hole.
1051
- *
1052
- * PROVES it rather than assuming: the path must be exactly that sibling's
1053
- * `feature.json`, must parse, and its `worktree.path` must point at a
1054
- * currently-existing directory. A parse failure, a missing registration, or a
1055
- * STALE pointer (worktree already removed) is NOT exempted.
1056
- *
1057
- * @param {string} relPath - forward-slash path relative to `root`
1058
- * @param {string} root - absolute path to the primary root
1059
- * @param {string} feature - the feature THIS finish is acting on (never exempted — its own scope is the shed's job)
1060
- */
628
+ /** PROVES it: exactly the sibling's feature.json, parseable, and `worktree.path` an existing dir. */
1061
629
  function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
1062
630
  const m = relPath.match(/^\.morph\/features\/([^/]+)\/feature\.json$/);
1063
631
  if (!m || m[1] === feature) return false;
@@ -1071,61 +639,14 @@ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
1071
639
  }
1072
640
 
1073
641
  /**
1074
- * Split the worktree's GITIGNORED leftovers into what this command may delete
1075
- * outright, what it must COPY OUT first, and what it cannot even enumerate.
1076
- *
1077
- * Why this exists: `git worktree remove` deletes gitignored files outright, and
1078
- * the clean-check that gates it uses `git status --porcelain`, which omits them
1079
- * BY DESIGN. So a user's `.env` or a local override is invisible to that gate
1080
- * and dies silently while the command reports success. Git treats gitignored as
1081
- * disposable; morph must not, because "not in git" is exactly the property that
1082
- * makes a file unrecoverable.
1083
- *
1084
- * The buckets, and why refusing is no longer one of them: the earlier version
1085
- * blocked on ANY unrecognised gitignored path, which in practice meant blocking
1086
- * on `.env` files that were byte-identical to the primary root's — nothing was
1087
- * at stake, and the only escape offered (`--discard-ignored`) also destroyed the
1088
- * genuinely unique `keys/` next to them. `triageIgnoredPaths` answers the
1089
- * question that actually decides whether bytes are lost (see ignored-triage.js),
1090
- * so the command can PROCEED in every case, backing up only what exists nowhere
1091
- * else. A refusal survives for exactly one reason now: the backup FAILED.
1092
- *
1093
- * shieldable — morph's own bookkeeping + anything EXCLUSIVE to this worktree.
1094
- * Copied to `finish-backup` before the removal; a failed copy is
1095
- * what declines the removal.
1096
- * discarded — build artifacts (regenerable) and REPLICABLE files (the root
1097
- * holds the same path with the same SHA-256). Deleted without a
1098
- * backup because neither loses bytes.
1099
- * blocking — the fail-CLOSED sentinel only: `git status --ignored` itself
1100
- * failed, so we cannot enumerate what is at stake.
1101
- *
1102
- * `--discard-ignored` no longer means "destroy the unique ones too, blind"; it
1103
- * means SKIP THE BACKUP — the same removal, without the copy.
1104
- *
1105
- * @param {string} worktreePath
1106
- * @param {string} rootPath - the primary root, for the replica comparison
1107
- * @param {{ discardIgnored?: boolean }} options
1108
- * @returns {{ shieldable: string[], blocking: string[], discarded: string[], replicable: string[] }}
1109
- *
1110
- * EXPORTADA para que o `finish --dry-run` (pré-voo) responda EXATAMENTE a
1111
- * mesma pergunta que o teardown real vai responder. Uma segunda enumeração de
1112
- * `git status --ignored` no finish.js — com a sua própria cópia de
1113
- * IGNORED_SHIELDABLE_PREFIXES — faria o pré-voo prometer uma coisa e o
1114
- * comando fazer outra, que é a única maneira de um dry-run ser pior que
1115
- * nenhum. Consumida read-only: o pré-voo não copia, não apaga, não decide.
642
+ * `git worktree remove` deletes gitignored files and `git status --porcelain` omits them. shieldable:
643
+ * backed up first; discarded: build artifacts and replicas of the root; blocking: enumeration failed.
1116
644
  */
1117
645
  export function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
1118
646
  let out;
1119
647
  try {
1120
- // `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
1121
- // paths with spaces or non-ASCII (a pt-BR codebase has both) stay intact.
1122
- // `=matching` (not the traditional default): an ignored directory whose own
1123
- // name matches no pattern — e.g. `MyProj/`, ignored only because EVERYTHING
1124
- // inside it (`bin/`, `obj/`) does — otherwise collapses into one opaque
1125
- // `MyProj/` entry, hiding the actual disposable-artifact names from
1126
- // isDisposableBuildArtifact. A directory that IS itself the matched pattern
1127
- // (`node_modules/`) still reports as one entry either way — verified
1128
- // empirically, not assumed.
648
+ // `-z` + execFile: NUL-delimited, no shell quoting. `=matching`: otherwise a dir ignored only through
649
+ // its children (`MyProj/` via `bin/`, `obj/`) collapses into one entry, hiding the artifact names.
1129
650
  out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
1130
651
  } catch {
1131
652
  return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [], replicable: [] };
@@ -1144,45 +665,20 @@ export function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
1144
665
  const { replicable, disposable, exclusive } = triageIgnoredPaths({ worktreePath, rootPath, paths: rest });
1145
666
  const shieldable = [...morphOwned, ...exclusive];
1146
667
 
1147
- // Explicit consent = SKIP THE BACKUP, not "widen what dies". The same paths
1148
- // are removed either way; the flag only decides whether a copy is made first.
1149
- // The list is never thrown away — these are the bytes that exist nowhere
1150
- // else, and reporting them is the least this can do.
668
+ // Consent = SKIP THE BACKUP, not widen what dies; the list is still reported.
1151
669
  if (options.discardIgnored) {
1152
670
  return { shieldable: [], blocking: [], discarded: [...disposable, ...replicable, ...shieldable], replicable };
1153
671
  }
1154
672
  return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
1155
673
  }
1156
674
 
1157
- // ── finish primitives (used by `morph-spec finish`) ───────────────────────
1158
- //
1159
- // The old `worktree finish` command dissolved here: `morph-spec finish` (see
1160
- // src/commands/state/finish.js) now orchestrates archive → integrate (merge OR
1161
- // PR) → teardown for BOTH the worktree and the common flow. These exported
1162
- // primitives keep the load-bearing safety (shed with backup, prove-or-decline
1163
- // worktree removal) in one hardened place, shared by every integration path.
1164
-
1165
- /**
1166
- * Shed the feature's OWN transient root-side bookkeeping so the merge can own
1167
- * this feature's state while the whole-root clean-check keeps guarding UNRELATED
1168
- * work. Backs up FIRST (see writeFeatureScopeBackup), then discards — the shed
1169
- * is safe WITHOUT having to be right about what is in scope: if the
1170
- * classification the merge implies turns out wrong, the bytes are still on
1171
- * disk. Worktree-flow only; the common flow committed the archive on the
1172
- * branch, so there is no untracked root-side scaffold to shed.
1173
- *
1174
- * @param {string} root
1175
- * @param {string} feature
1176
- * @param {{ stamp?: string }} [opts] - the finish's one stamp, shared with the
1177
- * teardown's backup so the two folders count as one backup
1178
- * @returns {{ shedBackup: string|null }} backup path if anything was shed
1179
- */
675
+ /** Backs up FIRST, then discards: safe without being right about what is in scope. */
1180
676
  export function shedRootFeatureScope(root, feature, { stamp } = {}) {
1181
677
  const featPathspec = `.morph/features/${feature}`;
1182
678
  let dirtyInScope = '';
1183
679
  try {
1184
680
  dirtyInScope = execFileSync('git', ['status', '--porcelain', '-uall', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
1185
- } catch { /* nothing in scope */ }
681
+ } catch {}
1186
682
 
1187
683
  if (dirtyInScope.trim() === '') return { shedBackup: null };
1188
684
 
@@ -1190,41 +686,21 @@ export function shedRootFeatureScope(root, feature, { stamp } = {}) {
1190
686
  // No backup, no shed. Falling through leaves the root dirty, so the caller's
1191
687
  // clean-check refuses — the exact fail-closed behaviour from before any shed.
1192
688
  if (shedBackup) {
1193
- try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing tracked to restore */ }
1194
- try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
689
+ try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch {}
690
+ try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch {}
1195
691
  }
1196
692
  return { shedBackup };
1197
693
  }
1198
694
 
1199
695
  /**
1200
- * The primary root's uncommitted paths that are NOT explained — everything
1201
- * except a SIBLING feature's own LIVE worktree registration (see
1202
- * isLiveSiblingWorktreeRegistration, the exemption that keeps one open sibling
1203
- * from blocking every other feature's finish). `ok:false` means the status call
1204
- * itself failed; an empty `dirt` means clean enough to merge into.
1205
- *
1206
- * `exemptOwnScope` additionally forgives everything under
1207
- * `.morph/features/{feature}/` — the EXACT pathspec `shedRootFeatureScope`
1208
- * backs up and discards. It exists so `finish` can ask "is the root clean
1209
- * enough to merge into?" BEFORE archiving anything, where the shed has not run
1210
- * yet and the feature's own scaffold is still on disk. Without it that early
1211
- * question answers "dirty" for a root that the shed would have cleaned, and a
1212
- * finish that works today would start being refused.
1213
- *
1214
- * Callers must keep the post-shed check: this one is an early refusal so the
1215
- * archive never happens, not the authoritative gate.
1216
- *
1217
- * @param {string} root
1218
- * @param {string} feature - the feature THIS finish acts on (its own scope is the shed's job)
1219
- * @param {{ exemptOwnScope?: boolean }} [opts]
1220
- * @returns {{ ok: boolean, dirt: string[] }}
696
+ * `exemptOwnScope` forgives `.morph/features/{feature}/` so `finish` can ask before the shed runs;
697
+ * callers must keep the post-shed check, the authoritative gate. `ok:false` = the status call failed.
1221
698
  */
1222
699
  export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
1223
700
  let rootStatusRaw;
1224
701
  try {
1225
- // `--untracked-files=all`, not the default: the default collapses an
1226
- // entirely-untracked directory into one entry, which would never match the
1227
- // exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects.
702
+ // `--untracked-files=all`: the default collapses an untracked dir into one entry that never
703
+ // matches the exact `.../feature.json` path.
1228
704
  rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
1229
705
  } catch {
1230
706
  return { ok: false, dirt: [] };
@@ -1240,27 +716,8 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
1240
716
  }
1241
717
 
1242
718
  /**
1243
- * Clear the root-side `.worktree` registration for a finished feature. Runs
1244
- * whenever the worktree is REMOVED — including `--pr`, where no merge landed —
1245
- * so a stale registration never trips the anti-dupe guard or the next finish's
1246
- * root-clean.
1247
- *
1248
- * A registration that was committed on the default branch is cleared by a commit
1249
- * scoped to the feature's own pathspec. An untracked scaffold (what
1250
- * `create --worktree` leaves at the root) is never committed: the branch owns
1251
- * the feature, and committing the skeleton would put it on the default next to
1252
- * the archived copy. When the feature is already archived the skeleton is shed,
1253
- * backup first, by the same primitive `finish` uses.
1254
- *
1255
- * Written DIRECTLY (never saveState, which re-persists every sibling's
1256
- * feature.json and can dirty the root outside this command's pathspec). The thin
1257
- * root index carries only {status, updatedAt}, never `worktree`. Fail-open:
1258
- * metadata cleanup is never a reason to fail an integration that already landed.
1259
- *
1260
- * @param {string} root
1261
- * @param {string} feature
1262
- * @param {{ stamp?: string }} [opts] - the caller's backup stamp, for the shed
1263
- * @returns {Promise<{ shedBackup: string|null }>}
719
+ * Written DIRECTLY, never via saveState (which re-persists every sibling and can dirty the root
720
+ * outside this pathspec). An untracked scaffold is never committed. Fail-open.
1264
721
  */
1265
722
  export async function clearRootWorktreeRegistration(root, feature, { stamp } = {}) {
1266
723
  const featPathspec = `.morph/features/${feature}`;
@@ -1275,7 +732,7 @@ export async function clearRootWorktreeRegistration(root, feature, { stamp } = {
1275
732
  atomicWriteJson(rootFjPath, fj);
1276
733
  }
1277
734
  }
1278
- } catch { /* fail-open */ }
735
+ } catch {}
1279
736
 
1280
737
  try {
1281
738
  const tracked = execFileSync('git', ['ls-files', '--', featPathspec], gitOpts).toString().trim() !== '';
@@ -1292,25 +749,7 @@ export async function clearRootWorktreeRegistration(root, feature, { stamp } = {
1292
749
  return { shedBackup: null };
1293
750
  }
1294
751
 
1295
- /**
1296
- * Clear a feature's `worktree` registration ONLY when it's dangling — the
1297
- * path it names no longer exists on disk (the directory was removed outside
1298
- * `morph-spec finish`/`worktree remove`: by hand, by `git worktree prune`, or
1299
- * by any tool that doesn't know about this pointer).
1300
- *
1301
- * Deliberately narrower than `clearRootWorktreeRegistration`: a registration
1302
- * naming a worktree that's still ALIVE is not a bug to repair — it's the
1303
- * anti-dupe guard's only signal (`runWorktreeSetup` Guard 3) and `verify`'s
1304
- * only signal for which cwd may persist (`decidePersistence`). Clearing a
1305
- * live registration would trade a true pointer for a false negative on both.
1306
- *
1307
- * Reuses `clearRootWorktreeRegistration` for the actual mutation (same
1308
- * atomic-write + scoped-commit contract) once the dangling predicate holds.
1309
- *
1310
- * @param {string} root
1311
- * @param {string} feature
1312
- * @returns {Promise<{ cleared: boolean }>}
1313
- */
752
+ /** Only when the named path is gone: a LIVE registration is the anti-dupe guard's and `verify`'s only signal. */
1314
753
  export async function clearDanglingWorktreeRegistration(root, feature) {
1315
754
  try {
1316
755
  const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
@@ -1321,24 +760,11 @@ export async function clearDanglingWorktreeRegistration(root, feature) {
1321
760
  await clearRootWorktreeRegistration(root, feature);
1322
761
  return { cleared: true };
1323
762
  } catch {
1324
- return { cleared: false }; // fail-open — malformed feature.json must never throw
763
+ return { cleared: false };
1325
764
  }
1326
765
  }
1327
766
 
1328
- /**
1329
- * Why `git worktree remove` failed, from its stderr.
1330
- *
1331
- * The distinction that matters is `in-use` — on Windows a process whose cwd is
1332
- * inside the worktree (another Claude Code instance, VS Code, a dev server, a
1333
- * stray terminal) pins the directory and the removal cannot proceed. That is a
1334
- * human-actionable cause with a specific fix ("close it"), and printing it is
1335
- * the difference between a message the user can act on and one that just says
1336
- * "failed" — which is what made a raw `git worktree remove --force` look like
1337
- * the only way forward.
1338
- *
1339
- * @param {string} stderr
1340
- * @returns {{ kind: 'in-use'|'unknown', detail: string }}
1341
- */
767
+ /** `in-use` (a process holding the dir on Windows) needs a human, so it is named apart. */
1342
768
  function classifyRemovalError(stderr) {
1343
769
  const text = String(stderr || '').trim();
1344
770
  const inUse = /permission denied|EPERM|EBUSY|being used by another process|resource busy|access is denied|is locked/i.test(text);
@@ -1349,19 +775,8 @@ function classifyRemovalError(stderr) {
1349
775
  }
1350
776
 
1351
777
  /**
1352
- * Run the removal, with one retry round after a short pause.
1353
- *
1354
- * The retry exists for the transient Windows handle (antivirus/indexer touching
1355
- * a file mid-removal) — the same class the repo already retries in
1356
- * `atomicWriteJson`. It deliberately does NOT help the `in-use` case: a held
1357
- * directory stays held, and that one needs the human, not another attempt.
1358
- *
1359
- * `--force` is safe HERE and only here: the caller proved `blockers.length ===
1360
- * 0` before calling, so there is nothing left for it to traverse out of.
1361
- *
1362
- * @param {string} worktreePath
1363
- * @param {string} root
1364
- * @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
778
+ * One retry for transient Windows handles (antivirus/indexer); it does not help `in-use`.
779
+ * `--force` is safe only here: the caller proved there are no blockers.
1365
780
  */
1366
781
  async function attemptWorktreeRemoval(worktreePath, root) {
1367
782
  const stderrs = [];
@@ -1379,19 +794,7 @@ async function attemptWorktreeRemoval(worktreePath, root) {
1379
794
  return { removed: false, failure: pickRemovalFailure(stderrs) };
1380
795
  }
1381
796
 
1382
- /**
1383
- * Which of the attempts' errors the user should read.
1384
- *
1385
- * The LAST one used to win, and it was the worst choice: when the first
1386
- * `git worktree remove` deletes the content and de-registers the tree but the
1387
- * directory itself is held (a session with its cwd there), every later attempt
1388
- * answers only `'…' is not a working tree` — an echo that hid the real cause
1389
- * (the lock) and made the hint send the user to a command that can no longer
1390
- * reach the tree. A lock anywhere wins; otherwise the first REAL error wins.
1391
- *
1392
- * @param {string[]} stderrs - one entry per failed attempt, in order
1393
- * @returns {{ kind: string, detail: string, alreadyDeregistered: boolean }}
1394
- */
797
+ /** A lock anywhere wins, else the first REAL error: later attempts only echo "is not a working tree". */
1395
798
  export function pickRemovalFailure(stderrs) {
1396
799
  const list = (stderrs || []).map((s) => String(s || '')).filter((s) => s.trim());
1397
800
  const notAWorktree = (s) => /is not a working tree/i.test(s);
@@ -1407,58 +810,20 @@ export function pickRemovalFailure(stderrs) {
1407
810
  }
1408
811
 
1409
812
  /**
1410
- * Remove a feature's worktree after its branch is integrated — PROVE-or-DECLINE.
1411
- * The single most dangerous step in the lifecycle, so it declines whenever it
1412
- * cannot prove removal is safe. Two independent ways `git worktree remove`
1413
- * (plain AND `--force`) destroys data, both ending in an exit-0 removal:
1414
- * 1. it FOLLOWS reparse points out of the worktree and deletes what they point
1415
- * at in the primary root (the real `.claude/`, `.morph/framework/`);
1416
- * 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
1417
- * and the clean-check cannot see them.
1418
- *
1419
- * A non-removal comes in two flavours the caller must not conflate — see the
1420
- * rollback comment below. `refused` (we proved it unsafe) restores the infra
1421
- * junctions; a FAILURE (git errored) deliberately leaves them down, reported in
1422
- * `infraLeftUnlinked`.
1423
- *
1424
- * `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
1425
- * branch is integrated), false for `--pr` (the open PR still needs the branch).
1426
- *
1427
- * `stamp` names the backup; `finish` passes the one it gave the shed, so both
1428
- * folders of that finish count as one backup under the retention.
1429
- *
1430
- * `relinkOnFailure` (the agent reaper) also restores the junctions when git
1431
- * FAILED, not only when we refused: the reaper removes trees on a schedule, and
1432
- * a lock that leaves a possibly-live agent without `.claude` kills its hooks.
1433
- * Off by default — a person retrying a failed removal by hand is what the
1434
- * unlinked state protects. A tree git already de-registered has nothing to link.
1435
- *
1436
- * @param {string} worktreePath
1437
- * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean, stamp?: string }} ctx
1438
- * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
1439
- * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
1440
- * relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
1441
- * removalFailure: {kind: string, detail: string}|null, rootShedBackup: string|null }>}
813
+ * PROVE-or-DECLINE. `refused` (proved unsafe) restores the junctions; a git FAILURE leaves them down
814
+ * (`infraLeftUnlinked`) unless `relinkOnFailure` (the reaper, whose agent may still be live).
1442
815
  */
1443
816
  export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, relinkOnFailure = false, stamp }) {
1444
- // Drop the infra junctions FIRST, without following them — this is what makes
1445
- // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1446
- //
1447
- // The ORDER IS LOAD-BEARING and must not be flipped: `git worktree remove`
1448
- // (and its --force fallback) follows reparse points OUT of the worktree and
1449
- // deletes the primary root's real `.claude/` and `.morph/framework/`.
1450
- // Whatever this unlink costs on a declined path is repaired by the rollback
1451
- // below — the reverse ordering has no repair, it destroys the installation.
817
+ // ORDER IS LOAD-BEARING: unlink the junctions BEFORE git touches the dir. `git worktree remove`
818
+ // follows them and deletes the root's installation; a declined path is repaired by the rollback below.
1452
819
  const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
1453
820
 
1454
821
  const scan = findReparsePoints(worktreePath);
1455
822
  const ignored = classifyIgnoredLeftovers(worktreePath, root, options);
1456
823
  const blockers = [...scan.points, ...scan.unprovable];
1457
824
 
1458
- // Back up ONLY once the removal is otherwise cleared: nothing is destroyed on
1459
- // a declined path (copying there is waste), and `blockers.length === 0` proves
1460
- // no reparse point exists — cpSync follows junctions, so backing up before
1461
- // that proof could copy an out-of-repo tree of unbounded size.
825
+ // Back up only once removal is cleared: `blockers.length === 0` proves no reparse point, and
826
+ // cpSync follows junctions (it could copy an unbounded out-of-repo tree).
1462
827
  let ignoredBackup = null;
1463
828
  const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
1464
829
  if (clearedToRemove && ignored.shieldable.length) {
@@ -1472,41 +837,25 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1472
837
  if (canRemove) {
1473
838
  const attempt = await attemptWorktreeRemoval(worktreePath, root);
1474
839
  worktreeRemoved = attempt.removed;
1475
- removalFailure = attempt.failure; // null once removed
840
+ removalFailure = attempt.failure;
1476
841
  }
1477
842
 
1478
843
  if (worktreeRemoved) {
1479
- // Three independent axes: remove-worktree (done), delete-branch (mode), and
1480
- // release-env (worktree existed and is gone). `--pr` removes the worktree
1481
- // but keeps the branch the open PR needs.
844
+ // `--pr` removes the worktree but keeps the branch the open PR needs.
1482
845
  if (deleteBranch) {
1483
- try { execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' }); } catch { /* fail-open */ }
846
+ try { execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' }); } catch {}
1484
847
  }
1485
- try { execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' }); } catch { /* fail-open */ }
1486
- // Fleet env: the worktree is gone, so no server can be running on its block.
848
+ try { execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' }); } catch {}
1487
849
  try {
1488
850
  const { releaseEnvBlock } = await import('../../lib/worktree/env-allocator.js');
1489
851
  const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
1490
852
  if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
1491
- } catch { /* fail-open */ }
853
+ } catch {}
1492
854
  ({ shedBackup: rootShedBackup } = await clearRootWorktreeRegistration(root, feature, { stamp }));
1493
855
  }
1494
856
 
1495
- // Rollback (A4) — ONLY on a REFUSAL, never on a FAILURE. The two look alike
1496
- // (worktree still there, junctions dropped) and must be treated as opposites:
1497
- //
1498
- // REFUSED (`!canRemove`): we proved removal unsafe, so the user keeps
1499
- // working in this worktree. Leaving the junctions down is "um estado pior
1500
- // que não ter tentado" — put them back.
1501
- // FAILED (`canRemove` but git errored): removal WAS the user's intent and
1502
- // the only obstacle is a lock. Re-linking here would restore the exact
1503
- // hazard that destroyed installations — the retry (ours, theirs, or a raw
1504
- // `git worktree remove --force` pasted from an old transcript) would then
1505
- // follow the junctions out. Leaving them down makes EVERY subsequent
1506
- // attempt safe, because there is nothing left to traverse.
1507
- //
1508
- // Fail-open on the refusal path: a failed re-link reports `relinked: false`
1509
- // and the caller falls back to telling the user to run `worktree link --all`.
857
+ // Relink ONLY on a REFUSAL: on a FAILURE removal was intended and only a lock remains; relinking
858
+ // would make the next retry (or a raw `--force`) follow the junctions out.
1510
859
  const refused = !canRemove;
1511
860
  let relinked = false;
1512
861
  const relinkAfterFailure = relinkOnFailure && !refused && !worktreeRemoved && !removalFailure?.alreadyDeregistered;
@@ -1516,21 +865,10 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1516
865
  relinked = true;
1517
866
  } catch { relinked = false; }
1518
867
  }
1519
- // What the caller must tell the user about on the FAILURE path: the worktree
1520
- // survived without its shared infra, on purpose.
1521
868
  const infraLeftUnlinked = (!worktreeRemoved && !refused && !relinked) ? unlinked : [];
1522
869
 
1523
- // Every branch names `morph-spec worktree remove` and NEVER `git worktree
1524
- // remove`. The raw command follows the junctions this function manages, and a
1525
- // decline is exactly the moment a frustrated reader copies whatever command
1526
- // the tool just printed — that copy is what emptied a primary root's
1527
- // `.claude/` and `.morph/framework/`. `worktree remove` re-runs this same
1528
- // PROVE-or-DECLINE path and accepts the same `--discard-ignored` consent, so
1529
- // there is no decline left where the raw command would be the right answer.
1530
- // A worktree with no feature identity (the ones `isolation: "worktree"`
1531
- // creates) can only be re-addressed by path — naming a feature that does not
1532
- // resolve would hand the reader a command that FAILS, and a failing safe
1533
- // command is exactly how people end up back at the raw one.
870
+ // Every hint names `morph-spec worktree remove`, NEVER `git worktree remove`: a frustrated reader
871
+ // copies what was printed. A tree with no feature identity is addressed by `--target` path.
1534
872
  const removeCmd = (addressByTarget || !isValidFeatureName(feature))
1535
873
  ? `morph-spec worktree remove --target "${worktreePath}"`
1536
874
  : `morph-spec worktree remove ${feature}`;
@@ -1543,10 +881,6 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1543
881
  : blockers.length
1544
882
  ? `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}\``
1545
883
  : ignored.shieldable.length && ignoredBackup === null
1546
- // The ONLY gitignored refusal left. The old hint was circular — it
1547
- // said "salve o que importa e rode <the command that just failed>".
1548
- // This one names WHAT is exclusive, what was replicable (so the
1549
- // reader stops worrying about those), and WHERE the copy was going.
1550
884
  ? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
1551
885
  + (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
1552
886
  + `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
@@ -1569,12 +903,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1569
903
  ignoredBackup,
1570
904
  discarded: ignored.discarded,
1571
905
  replicable: ignored.replicable,
1572
- // The infra reparse points this teardown dropped BEFORE letting git near
1573
- // the directory. Reported because it is the load-bearing safety step of the
1574
- // whole operation and was previously invisible: the user had no way to see
1575
- // that `.claude` was a junction into the primary root, nor that it was
1576
- // unlinked rather than followed. On a removal it is the proof the root's
1577
- // real `.claude/` was never at risk.
906
+ // Reported: the proof the root's real `.claude/` was unlinked, never followed.
1578
907
  unlinked,
1579
908
  blockers,
1580
909
  ignoredBlocking: ignored.blocking,
@@ -1588,19 +917,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1588
917
  };
1589
918
  }
1590
919
 
1591
- /**
1592
- * Is `cwd` the worktree we are about to remove, or inside it?
1593
- *
1594
- * Hoisted out of `finish` so BOTH callers share one answer. Windows holds a
1595
- * lock on a process's working directory, so a removal issued from inside the
1596
- * target fails — and in `finish`'s case it fails AFTER the archive is committed
1597
- * and the branch merged, leaving an orphan directory and a de-registered
1598
- * worktree. Refusing up front costs one `cd`.
1599
- *
1600
- * @param {string} cwd
1601
- * @param {string} worktreePath
1602
- * @returns {boolean}
1603
- */
920
+ /** Windows locks a process's cwd: removing from inside fails after the merge, so refuse up front. */
1604
921
  export function cwdIsInsideWorktree(cwd, worktreePath) {
1605
922
  const cwdNorm = resolve(cwd).replace(/\\/g, '/');
1606
923
  const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
@@ -1608,56 +925,15 @@ export function cwdIsInsideWorktree(cwd, worktreePath) {
1608
925
  }
1609
926
 
1610
927
  /**
1611
- * Is the Claude Code SESSION that ran this command anchored in the worktree?
1612
- *
1613
- * Not the same question as `cwdIsInsideWorktree`. That one sees the PROCESS
1614
- * cwd, and `cd <root> && morph-spec finish` answers it "no" — while the session
1615
- * stays anchored in the worktree: its CLAUDE_PROJECT_DIR is the worktree, and
1616
- * every project hook runs `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/…"`.
1617
- * Tearing that worktree down unlinks the junction those hooks resolve through,
1618
- * and the directory stays held by the session — so the removal fails half-way
1619
- * and every hook of every later tool call dies with MODULE_NOT_FOUND.
1620
- *
1621
- * The anchor arrives as MORPH_SESSION_PROJECT_DIR, exported by the
1622
- * `worktree-env-inject` PreToolUse hook (the Bash tool does not export
1623
- * CLAUDE_PROJECT_DIR itself); CLAUDE_PROJECT_DIR is honoured when present.
1624
- *
1625
- * @param {string} worktreePath
1626
- * @param {NodeJS.ProcessEnv} [env]
1627
- * @returns {boolean}
928
+ * The SESSION anchor, not the process cwd: tearing down the session's worktree kills every later hook
929
+ * (MODULE_NOT_FOUND). Read from MORPH_SESSION_PROJECT_DIR (set by worktree-env-inject) or CLAUDE_PROJECT_DIR.
1628
930
  */
1629
931
  export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
1630
932
  const anchor = env.MORPH_SESSION_PROJECT_DIR || env.CLAUDE_PROJECT_DIR || '';
1631
933
  return anchor ? cwdIsInsideWorktree(anchor, worktreePath) : false;
1632
934
  }
1633
935
 
1634
- // ── remove ───────────────────────────────────────────────────────────────
1635
-
1636
- /**
1637
- * Non-exiting core of `morph-spec worktree remove <feature>` /
1638
- * `morph-spec worktree remove --target <path>` — returns `{ out, code }` instead
1639
- * of printing and exiting, so the agent reaper can call it in a loop.
1640
- *
1641
- * WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
1642
- * junctions before letting git near the directory — used to be reachable ONLY
1643
- * through `morph-spec finish`. So every message that had to tell a user how to
1644
- * remove a worktree pointed at raw `git worktree remove`, which follows those
1645
- * junctions out and empties the primary root's `.claude/` and
1646
- * `.morph/framework/`. This command is the safe answer those messages now give.
1647
- *
1648
- * Two ways to address the target, because there are two kinds of worktree:
1649
- * <feature> — a morph worktree, resolved through `findWorktreeForFeature`
1650
- * --target <path> — any registered worktree, including the ones the Agent
1651
- * tool's `isolation: "worktree"` creates, which have no
1652
- * feature registration for the lookup to find.
1653
- *
1654
- * It removes ONLY. No archive, no merge, and the branch survives by default —
1655
- * closing a feature is still `morph-spec finish`.
1656
- *
1657
- * @param {string|undefined} featureArg
1658
- * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, relinkOnFailure?: boolean, cwd?: string }} [options]
1659
- * @returns {Promise<{ out: object, code: 0|1 }>}
1660
- */
936
+ /** Non-exiting core of `worktree remove`: removes ONLY, the branch survives by default. */
1661
937
  export async function runWorktreeRemove(featureArg, options = {}) {
1662
938
  const cwdArg = options.cwd || process.cwd();
1663
939
  const outcome = (out, code) => ({ out, code });
@@ -1668,15 +944,11 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1668
944
  let worktreePath = null;
1669
945
  let feature = featureArg || null;
1670
946
  let branch = null;
1671
- // Can a re-run be addressed as `remove <feature>`? Only when the feature name
1672
- // is what resolved the worktree in the first place.
1673
947
  let featureAddressable = !options.target;
1674
948
 
1675
949
  if (options.target) {
1676
950
  worktreePath = resolve(options.target).replace(/\\/g, '/');
1677
- // The target MUST be a registered worktree. Without this the command would
1678
- // hand an arbitrary path to `git worktree remove` — and `--target ../..`
1679
- // is the same path-traversal class already fixed in delete/archive.
951
+ // Must be a registered worktree: `--target ../..` is a path traversal otherwise.
1680
952
  const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
1681
953
  if (!entry) {
1682
954
  return outcome({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
@@ -1687,9 +959,6 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1687
959
  hint: 'a raiz primária nunca é removível por este comando' }, 1);
1688
960
  }
1689
961
  branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
1690
- // Best-effort feature identity — used for the backup/env-release bookkeeping
1691
- // inside the teardown. A `morph/{f}` branch means `remove <feature>` would
1692
- // resolve too, so re-run advice may name it; anything else stays --target.
1693
962
  if (!feature && branch?.startsWith('morph/')) {
1694
963
  feature = branch.slice('morph/'.length);
1695
964
  featureAddressable = isValidFeatureName(feature);
@@ -1708,30 +977,20 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1708
977
  branch = found.branch;
1709
978
  }
1710
979
 
1711
- // Same guard finish applies, for the same Windows reason — and this command
1712
- // is MORE likely to hit it: the natural moment to run it is right after
1713
- // working inside the worktree.
1714
980
  if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
1715
981
  return outcome({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
1716
982
  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);
1717
983
  }
1718
984
 
1719
- // The SESSION's anchor, not the process cwd (see sessionAnchoredInWorktree):
1720
- // refusing here, before the junctions are touched, leaves the session's hooks
1721
- // intact. Unlike finish there is nothing else to do in this command, so the
1722
- // refusal IS the deferral.
985
+ // The SESSION's anchor: refusing before the junctions are touched leaves its hooks intact.
1723
986
  if (sessionAnchoredInWorktree(worktreePath)) {
1724
987
  const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
1725
988
  return outcome({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
1726
989
  hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
1727
990
  }
1728
991
 
1729
- // `--if-clean` — the WorktreeRemove hook's contract: Claude Code fires it
1730
- // when it believes the tree is disposable, but "disposable" is the user's
1731
- // call, not the harness's. Any uncommitted or untracked file refuses the
1732
- // removal BEFORE the junctions are touched, so a refusal leaves the tree
1733
- // exactly as it was (no rollback needed, nothing to relink). The infra
1734
- // links and the env manifest are excluded from git, so they never count.
992
+ // `--if-clean` (WorktreeRemove hook): any uncommitted file refuses BEFORE the junctions are touched.
993
+ // Infra links and the env manifest are excluded from git, so they never count.
1735
994
  if (options.ifClean) {
1736
995
  let status;
1737
996
  try {
@@ -1747,27 +1006,18 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1747
1006
  }
1748
1007
  }
1749
1008
 
1750
- // A leftover temporary .graphifyignore (a `graph refresh` killed mid-run) is a
1751
- // copy of the root's rules: not a file to back up. Deleted only now, after
1752
- // every refusal, so a refused removal leaves the tree as it was.
1009
+ // Leftover temp .graphifyignore: deleted only after every refusal, so a refused removal leaves the tree as it was.
1753
1010
  dropLeftoverIgnore(worktreePath);
1754
1011
 
1755
- // The env-registry key this tree allocated under: `<f>` for morph/<f>,
1756
- // `task/<n>` for morph-task/<n>. Read BEFORE the teardown (the entry is gone
1757
- // from `git worktree list` afterwards). The old `'worktree'` placeholder
1758
- // meant every --target removal released a slot nobody held and leaked the
1759
- // one the tree actually used.
1012
+ // Read BEFORE the teardown: the entry is gone from `git worktree list` afterwards.
1760
1013
  const envKey = envKeyForWorktree(worktreePath, root);
1761
1014
 
1762
- // The tree's compose stack goes down BEFORE the tree: a running bind-mounted
1763
- // container holds the directory (Windows) and turns a clean removal into a
1764
- // removalFailure. Volumes survive — removing a worktree is not closing the
1765
- // feature, and a re-`setup` should find its seeded database intact.
1015
+ // Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows. Volumes survive.
1766
1016
  let e2e = null;
1767
1017
  try {
1768
1018
  const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
1769
1019
  e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
1770
- } catch { e2e = null; /* fail-open */ }
1020
+ } catch { e2e = null; }
1771
1021
 
1772
1022
  // The stamp this removal's backup is written under — protected from the
1773
1023
  // retention that runs after it.
@@ -1778,8 +1028,7 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1778
1028
  stamp,
1779
1029
  branch,
1780
1030
  options: { discardIgnored: options.discardIgnored === true },
1781
- // Removing a worktree is not closing a feature: the branch is the work, and
1782
- // deleting it here would discard unintegrated commits. Opt-in only.
1031
+ // The branch is the work: deleting it would discard unintegrated commits. Opt-in only.
1783
1032
  deleteBranch: options.deleteBranch === true,
1784
1033
  relinkOnFailure: options.relinkOnFailure === true,
1785
1034
  // Re-run advice must address the worktree the way it was addressable here:
@@ -1793,16 +1042,10 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1793
1042
  ...(branch ? { branch } : {}),
1794
1043
  ...(feature ? { feature } : {}),
1795
1044
  };
1796
- // Reported on BOTH paths: on a removal it is the proof the primary root's
1797
- // real `.claude/`/`.morph/framework/` were unlinked rather than followed; on
1798
- // a refusal it is what `relinked` did or did not put back.
1799
1045
  if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
1800
1046
  if (e2e?.attempted) out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes: false };
1801
1047
  if (teardown.worktreeRemoved) {
1802
1048
  if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1803
- // Broken out of `discardedIgnored` on purpose: "deleted" and "deleted
1804
- // because the identical bytes are still in the root" are not the same
1805
- // report, and the second is the one that needs no follow-up from the user.
1806
1049
  if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
1807
1050
  if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
1808
1051
  if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
@@ -1824,28 +1067,12 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1824
1067
  return outcome(out, teardown.worktreeRemoved ? 0 : 1);
1825
1068
  }
1826
1069
 
1827
- /**
1828
- * `morph-spec worktree remove` — prints the JSON verdict and exits with its code.
1829
- * @param {string|undefined} featureArg
1830
- * @param {object} [options] - see `runWorktreeRemove`
1831
- */
1832
1070
  export async function worktreeRemoveCommand(featureArg, options = {}) {
1833
1071
  const { out, code } = await runWorktreeRemove(featureArg, options);
1834
1072
  console.log(JSON.stringify(out));
1835
1073
  process.exit(code);
1836
1074
  }
1837
1075
 
1838
- // ── list ─────────────────────────────────────────────────────────────────
1839
-
1840
- /**
1841
- * Command handler for `morph-spec worktree list [--json]`.
1842
- *
1843
- * Lists active `morph/{feature}` worktrees with their derived phase, status,
1844
- * and any pending approval gates — visibility across multi-instance sessions.
1845
- * Fail-open: outside a git repo (or on any git error), reports an empty list.
1846
- *
1847
- * @param {{ json?: boolean, cwd?: string }} [options]
1848
- */
1849
1076
  export async function worktreeListCommand(options = {}) {
1850
1077
  const cwd = options.cwd || process.cwd();
1851
1078
 
@@ -1864,10 +1091,10 @@ export async function worktreeListCommand(options = {}) {
1864
1091
 
1865
1092
  for (const entry of entries) {
1866
1093
  const m = entry.branch && entry.branch.match(/^refs\/heads\/morph\/(.+)$/);
1867
- if (!m) continue; // skip the primary root entry and any non-morph worktree
1094
+ if (!m) continue;
1868
1095
  const feature = m[1];
1869
1096
 
1870
- let phase = 'proposal';
1097
+ let phase = 'plan';
1871
1098
  let status = 'unknown';
1872
1099
  let pendingGates = [];
1873
1100
  try {
@@ -1892,7 +1119,6 @@ export async function worktreeListCommand(options = {}) {
1892
1119
  process.exit(0);
1893
1120
  }
1894
1121
 
1895
- /** Render `worktree list` output — JSON or a human table (chalk). */
1896
1122
  function printWorktreeList(worktrees, options) {
1897
1123
  if (options.json) {
1898
1124
  console.log(JSON.stringify({ worktrees }));