canopy-cli 3.1.1__tar.gz → 3.1.2__tar.gz

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 (218) hide show
  1. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/.github/workflows/ci.yml +0 -35
  2. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/CHANGELOG.md +32 -0
  3. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/PKG-INFO +2 -2
  4. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/README.md +1 -1
  5. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/__init__.py +1 -1
  6. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/bootstrap.py +26 -2
  7. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/doctor.py +109 -1
  8. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/switch.py +37 -12
  9. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/main.py +13 -25
  10. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/features/coordinator.py +18 -7
  11. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/repo.py +9 -0
  12. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/mcp/server.py +4 -8
  13. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/discovery.py +41 -0
  14. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_bootstrap.py +17 -0
  15. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_coordinator.py +47 -0
  16. canopy_cli-3.1.2/tests/test_discovery_worktrees.py +50 -0
  17. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_doctor.py +78 -0
  18. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_switch.py +92 -0
  19. canopy_cli-3.1.1/vscode-extension/.gitignore +0 -5
  20. canopy_cli-3.1.1/vscode-extension/.vscodeignore +0 -14
  21. canopy_cli-3.1.1/vscode-extension/CHANGELOG.md +0 -128
  22. canopy_cli-3.1.1/vscode-extension/LICENSE +0 -21
  23. canopy_cli-3.1.1/vscode-extension/README.md +0 -82
  24. canopy_cli-3.1.1/vscode-extension/esbuild.config.mjs +0 -71
  25. canopy_cli-3.1.1/vscode-extension/jest.config.ts +0 -22
  26. canopy_cli-3.1.1/vscode-extension/media/canopy-icon.png +0 -0
  27. canopy_cli-3.1.1/vscode-extension/media/canopy-icon.svg +0 -588
  28. canopy_cli-3.1.1/vscode-extension/media/screenshots/dashboard-feature.png +0 -0
  29. canopy_cli-3.1.1/vscode-extension/media/screenshots/dashboard-global.png +0 -0
  30. canopy_cli-3.1.1/vscode-extension/package-lock.json +0 -9054
  31. canopy_cli-3.1.1/vscode-extension/package.json +0 -297
  32. canopy_cli-3.1.1/vscode-extension/src/__mocks__/vscode.ts +0 -98
  33. canopy_cli-3.1.1/vscode-extension/src/canopyCli.test.ts +0 -196
  34. canopy_cli-3.1.1/vscode-extension/src/canopyCli.ts +0 -862
  35. canopy_cli-3.1.1/vscode-extension/src/canopyClient.ts +0 -704
  36. canopy_cli-3.1.1/vscode-extension/src/cliResolver.test.ts +0 -122
  37. canopy_cli-3.1.1/vscode-extension/src/cliResolver.ts +0 -134
  38. canopy_cli-3.1.1/vscode-extension/src/commands/createFeature.ts +0 -161
  39. canopy_cli-3.1.1/vscode-extension/src/commands/createFeatureFromIssue.ts +0 -85
  40. canopy_cli-3.1.1/vscode-extension/src/commands/installBackend.ts +0 -218
  41. canopy_cli-3.1.1/vscode-extension/src/commands/setupWizard.ts +0 -103
  42. canopy_cli-3.1.1/vscode-extension/src/extension.ts +0 -828
  43. canopy_cli-3.1.1/vscode-extension/src/mcpResolver.ts +0 -130
  44. canopy_cli-3.1.1/vscode-extension/src/stateReader.test.ts +0 -273
  45. canopy_cli-3.1.1/vscode-extension/src/stateReader.ts +0 -287
  46. canopy_cli-3.1.1/vscode-extension/src/statusBar.ts +0 -96
  47. canopy_cli-3.1.1/vscode-extension/src/types.ts +0 -192
  48. canopy_cli-3.1.1/vscode-extension/src/views/GlobalDashboardPanel.ts +0 -1020
  49. canopy_cli-3.1.1/vscode-extension/src/views/canopyTreeProvider.ts +0 -269
  50. canopy_cli-3.1.1/vscode-extension/src/views/themeShim.ts +0 -113
  51. canopy_cli-3.1.1/vscode-extension/src/watchers.ts +0 -79
  52. canopy_cli-3.1.1/vscode-extension/src/webview/cockpitPanel.ts +0 -395
  53. canopy_cli-3.1.1/vscode-extension/src/webview/components/branchLedger.ts +0 -72
  54. canopy_cli-3.1.1/vscode-extension/src/webview/components/bridge.ts +0 -72
  55. canopy_cli-3.1.1/vscode-extension/src/webview/components/capReachedModal.ts +0 -112
  56. canopy_cli-3.1.1/vscode-extension/src/webview/components/focusTile.ts +0 -139
  57. canopy_cli-3.1.1/vscode-extension/src/webview/components/newFeatureForm.ts +0 -174
  58. canopy_cli-3.1.1/vscode-extension/src/webview/components/styles.ts +0 -691
  59. canopy_cli-3.1.1/vscode-extension/src/webview/components/triageFeed.ts +0 -91
  60. canopy_cli-3.1.1/vscode-extension/src/webview/components/util.ts +0 -40
  61. canopy_cli-3.1.1/vscode-extension/src/webview/components/worktreeRow.ts +0 -71
  62. canopy_cli-3.1.1/vscode-extension/src/webview/dashboardPanel.ts +0 -904
  63. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/Dashboard.tsx +0 -137
  64. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/FeatureView.tsx +0 -972
  65. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/GlobalView.tsx +0 -649
  66. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/Skeletons.tsx +0 -38
  67. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/diff.ts +0 -118
  68. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/index.tsx +0 -17
  69. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/protocol.ts +0 -129
  70. canopy_cli-3.1.1/vscode-extension/src/webview/global-dashboard/vscode.ts +0 -30
  71. canopy_cli-3.1.1/vscode-extension/src/webview/newFeaturePanel.ts +0 -322
  72. canopy_cli-3.1.1/vscode-extension/src/webview/shared/pastel.css +0 -801
  73. canopy_cli-3.1.1/vscode-extension/src/webview/themes/index.ts +0 -42
  74. canopy_cli-3.1.1/vscode-extension/src/webview/themes/minimal.ts +0 -62
  75. canopy_cli-3.1.1/vscode-extension/src/webview/themes/navy.ts +0 -60
  76. canopy_cli-3.1.1/vscode-extension/src/webview/themes/render.ts +0 -96
  77. canopy_cli-3.1.1/vscode-extension/src/webview/themes/types.ts +0 -104
  78. canopy_cli-3.1.1/vscode-extension/tsconfig.json +0 -20
  79. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/.github/workflows/release.yml +0 -0
  80. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/.gitignore +0 -0
  81. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/AGENTS.md +0 -0
  82. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/CLAUDE.md +0 -0
  83. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/agents.md +0 -0
  84. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/architecture/providers.md +0 -0
  85. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/architecture.md +0 -0
  86. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/archive/test-plan.md +0 -0
  87. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/canopy-banner.svg +0 -0
  88. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-commit.svg +0 -0
  89. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-config.svg +0 -0
  90. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-done.svg +0 -0
  91. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-drift.svg +0 -0
  92. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-init.svg +0 -0
  93. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-list.svg +0 -0
  94. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-push.svg +0 -0
  95. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-review.svg +0 -0
  96. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-setup-agent.svg +0 -0
  97. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-stage.svg +0 -0
  98. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-state.svg +0 -0
  99. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-status.svg +0 -0
  100. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-switch.svg +0 -0
  101. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-triage.svg +0 -0
  102. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/cli-worktree.svg +0 -0
  103. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/commands.md +0 -0
  104. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/concepts.md +0 -0
  105. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/mcp.md +0 -0
  106. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/docs/workspace.md +0 -0
  107. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/gen_svgs.py +0 -0
  108. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/mockups/canopy-vscode.html +0 -0
  109. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/pyproject.toml +0 -0
  110. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/__init__.py +0 -0
  111. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/aliases.py +0 -0
  112. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/augments.py +0 -0
  113. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/bot_resolutions.py +0 -0
  114. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/bot_status.py +0 -0
  115. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/commit.py +0 -0
  116. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/conflicts.py +0 -0
  117. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/draft_replies.py +0 -0
  118. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/drift.py +0 -0
  119. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/errors.py +0 -0
  120. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/evacuate.py +0 -0
  121. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/feature_state.py +0 -0
  122. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/historian.py +0 -0
  123. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/ide_workspace.py +0 -0
  124. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/last_visit.py +0 -0
  125. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/migrate_slots.py +0 -0
  126. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/preflight_state.py +0 -0
  127. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/push.py +0 -0
  128. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/reads.py +0 -0
  129. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/resume.py +0 -0
  130. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/review_filter.py +0 -0
  131. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/ship.py +0 -0
  132. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/slot_details.py +0 -0
  133. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/slot_load.py +0 -0
  134. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/slots.py +0 -0
  135. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/stash.py +0 -0
  136. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/switch_preflight.py +0 -0
  137. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/thread_actions.py +0 -0
  138. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/thread_resolutions.py +0 -0
  139. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/actions/triage.py +0 -0
  140. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent/__init__.py +0 -0
  141. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent/runner.py +0 -0
  142. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent_setup/__init__.py +0 -0
  143. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent_setup/skills/augment-canopy/SKILL.md +0 -0
  144. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/agent_setup/skills/using-canopy/SKILL.md +0 -0
  145. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/__init__.py +0 -0
  146. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/render.py +0 -0
  147. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/cli/ui.py +0 -0
  148. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/features/__init__.py +0 -0
  149. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/__init__.py +0 -0
  150. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/hooks.py +0 -0
  151. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/multi.py +0 -0
  152. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/git/templates/post-checkout.py +0 -0
  153. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/graph/__init__.py +0 -0
  154. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/__init__.py +0 -0
  155. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/github.py +0 -0
  156. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/linear.py +0 -0
  157. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/integrations/precommit.py +0 -0
  158. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/mcp/__init__.py +0 -0
  159. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/mcp/client.py +0 -0
  160. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/__init__.py +0 -0
  161. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/github_issues.py +0 -0
  162. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/linear.py +0 -0
  163. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/providers/types.py +0 -0
  164. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/__init__.py +0 -0
  165. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/config.py +0 -0
  166. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/context.py +0 -0
  167. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/src/canopy/workspace/workspace.py +0 -0
  168. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/__init__.py +0 -0
  169. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/conftest.py +0 -0
  170. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_action_errors.py +0 -0
  171. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_agent_setup.py +0 -0
  172. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_aliases.py +0 -0
  173. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_augments.py +0 -0
  174. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_bot_resolutions.py +0 -0
  175. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_bot_status.py +0 -0
  176. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_ci_status.py +0 -0
  177. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_commit.py +0 -0
  178. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_commit_push_integration.py +0 -0
  179. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_config.py +0 -0
  180. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_config_done.py +0 -0
  181. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_conflicts.py +0 -0
  182. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_context_stage.py +0 -0
  183. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_draft_replies.py +0 -0
  184. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_drift.py +0 -0
  185. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_evacuate.py +0 -0
  186. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_feature_state.py +0 -0
  187. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_feature_state_bot.py +0 -0
  188. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_historian.py +0 -0
  189. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_hooks.py +0 -0
  190. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_last_visit.py +0 -0
  191. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_linear_integration.py +0 -0
  192. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_mcp_server_worktree_create.py +0 -0
  193. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_migrate_slots.py +0 -0
  194. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_new_commands.py +0 -0
  195. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_precommit.py +0 -0
  196. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_github_issues.py +0 -0
  197. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_linear.py +0 -0
  198. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_registry.py +0 -0
  199. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_providers_types.py +0 -0
  200. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_push.py +0 -0
  201. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_reads.py +0 -0
  202. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_repo.py +0 -0
  203. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_resume.py +0 -0
  204. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_review.py +0 -0
  205. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_review_filter.py +0 -0
  206. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_runner.py +0 -0
  207. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_ship.py +0 -0
  208. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_slot_details.py +0 -0
  209. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_slot_load.py +0 -0
  210. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_slots.py +0 -0
  211. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_stash_features.py +0 -0
  212. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_switch_action.py +0 -0
  213. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_switch_preflight.py +0 -0
  214. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_thread_actions.py +0 -0
  215. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_thread_graphql.py +0 -0
  216. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_triage.py +0 -0
  217. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_workspace.py +0 -0
  218. {canopy_cli-3.1.1 → canopy_cli-3.1.2}/tests/test_worktree_features.py +0 -0
@@ -39,38 +39,3 @@ jobs:
39
39
 
40
40
  - name: Run pytest
41
41
  run: pytest tests/ -v --maxfail=5
42
-
43
- extension-build:
44
- name: VSCode extension build
45
- runs-on: ubuntu-latest
46
- defaults:
47
- run:
48
- working-directory: vscode-extension
49
- steps:
50
- - uses: actions/checkout@v4
51
-
52
- - uses: actions/setup-node@v4
53
- with:
54
- node-version: "20"
55
- cache: npm
56
- cache-dependency-path: vscode-extension/package-lock.json
57
-
58
- - name: Install dependencies
59
- run: npm ci
60
-
61
- - name: Type-check
62
- run: npx tsc --noEmit
63
-
64
- - name: Bundle with esbuild
65
- run: npm run build
66
-
67
- - name: Package VSIX (sanity check)
68
- run: npx vsce package --allow-missing-repository
69
-
70
- - name: Upload VSIX artifact
71
- uses: actions/upload-artifact@v4
72
- with:
73
- name: canopy-vsix
74
- path: vscode-extension/*.vsix
75
- if-no-files-found: error
76
- retention-days: 14
@@ -4,6 +4,38 @@ Tracks the Python side (CLI + MCP server). The VSCode extension has its own [vsc
4
4
 
5
5
  Versions follow semver. Pre-1.0 — minor bumps may add features or break behavior; the README is the source-of-truth contract.
6
6
 
7
+ ## 3.1.2 — 2026-07-04
8
+
9
+ Slot-model consistency fixes from canopy-test dogfooding.
10
+
11
+ ### Fixed
12
+ - `switch`: on the cold-Y fall-through (Y has a slots.json entry but this
13
+ repo's slot dir is missing), the vacating feature's slot is reclaimed for
14
+ the outgoing feature instead of allocating a fresh one — previously the
15
+ about-to-be-freed slot counted against the cap and raised a bogus
16
+ `no_free_slot`.
17
+ - `switch`: precondition failures raised before any git mutation
18
+ (`no_free_slot`, `unknown_slot`, `evict_to_occupied`,
19
+ `warm_worktree_dirty_on_promote`) no longer stamp the `in_flight` marker
20
+ when no repo has been touched — a clean no-op failure used to brick every
21
+ subsequent switch via `slot_state_inconsistent`.
22
+ - `doctor`: new `slot_repo_worktree_missing` check (+ auto-repair via
23
+ `git worktree prune` + `worktree add`) catches half-materialized slots
24
+ where a slot holds a feature but one repo's worktree is gone.
25
+ - `doctor`: the pre-3.0 `worktree_orphan` check now skips `worktree-N` slot
26
+ dirs — `doctor --fix` no longer deletes warm slots.
27
+ - `worktree_bootstrap`: resolves worktree paths via slots.json (the 3.0
28
+ source of truth) instead of the legacy `features.json` cache, which is
29
+ empty in 3.0 — bootstrap raised `no_worktrees` for every warm feature.
30
+ Falls back to the legacy cache for pre-3.0 workspaces.
31
+ - `canopy init` / `workspace_reinit`: existing worktrees are reported by
32
+ occupant feature (resolved via slots.json) instead of listing slot ids
33
+ (`worktree-N`) as if they were feature names.
34
+ - `coordinator.status()` / `feature_changes`: honor the per-repo `branches`
35
+ map (`lane.branch_for`) instead of assuming branch == feature name —
36
+ mismatched-naming features no longer mis-report as having no branch or no
37
+ changes.
38
+
7
39
  ## 3.1.1 — 2026-05-31
8
40
 
9
41
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: canopy-cli
3
- Version: 3.1.1
3
+ Version: 3.1.2
4
4
  Summary: Workspace-first development orchestrator for AI coding agents
5
5
  Project-URL: Homepage, https://github.com/ashmitb95/canopy
6
6
  Project-URL: Source, https://github.com/ashmitb95/canopy
@@ -254,7 +254,7 @@ canopy doctor # diagnose drift / staleness
254
254
  <img src="docs/cli-state.svg" alt="canopy state" width="720">
255
255
  </p>
256
256
 
257
- The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) reading the same state the agent reads.
257
+ The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) (source at [`ashmitb95/canopy-dashboard`](https://github.com/ashmitb95/canopy-dashboard)) reading the same state the agent reads.
258
258
 
259
259
  Full CLI reference: [docs/commands.md](docs/commands.md).
260
260
 
@@ -226,7 +226,7 @@ canopy doctor # diagnose drift / staleness
226
226
  <img src="docs/cli-state.svg" alt="canopy state" width="720">
227
227
  </p>
228
228
 
229
- The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) reading the same state the agent reads.
229
+ The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) (source at [`ashmitb95/canopy-dashboard`](https://github.com/ashmitb95/canopy-dashboard)) reading the same state the agent reads.
230
230
 
231
231
  Full CLI reference: [docs/commands.md](docs/commands.md).
232
232
 
@@ -1,2 +1,2 @@
1
1
  """Canopy — workspace-first development orchestrator."""
2
- __version__ = "3.1.1"
2
+ __version__ = "3.1.2"
@@ -63,7 +63,10 @@ def bootstrap_feature(
63
63
  if not worktree_paths:
64
64
  raise BlockerError(
65
65
  code="no_worktrees",
66
- what=f"feature '{feature_name}' has no worktree paths recorded",
66
+ what=(
67
+ f"feature '{feature_name}' is not warm in any slot "
68
+ f"(nothing to bootstrap) — `canopy switch {feature_name}` first"
69
+ ),
67
70
  )
68
71
 
69
72
  results: dict[str, dict[str, Any]] = {}
@@ -235,7 +238,28 @@ def _validate_steps(steps: Iterable[str] | None) -> set[str]:
235
238
  def _resolve_worktree_paths(
236
239
  workspace: Workspace, feature_name: str,
237
240
  ) -> dict[str, Path]:
238
- """Pull recorded worktree paths from features.json."""
241
+ """Resolve each repo's warm worktree dir for ``feature_name``.
242
+
243
+ Wave 3.0: the authoritative source is slots.json — a warm feature's
244
+ per-repo worktrees live under its slot at
245
+ ``.canopy/worktrees/worktree-N/<repo>``. Falls back to the legacy
246
+ ``features.json`` ``worktree_paths`` cache for pre-3.0 workspaces (no
247
+ slots.json). The old code read only the legacy cache, which is empty in
248
+ 3.0 — so bootstrap raised ``no_worktrees`` for every warm feature.
249
+ """
250
+ from . import slots as slots_mod
251
+ from .aliases import repos_for_feature
252
+
253
+ slot_id = slots_mod.slot_for_feature(workspace, feature_name)
254
+ if slot_id is not None:
255
+ out: dict[str, Path] = {}
256
+ for repo_name in repos_for_feature(workspace, feature_name):
257
+ p = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
258
+ if (p / ".git").exists():
259
+ out[repo_name] = p
260
+ return out
261
+
262
+ # Legacy pre-3.0 fallback: features.json worktree_paths cache.
239
263
  import json
240
264
  path = workspace.config.root / ".canopy" / "features.json"
241
265
  if not path.exists():
@@ -222,7 +222,14 @@ def check_active_feature_path_missing(workspace: Workspace) -> list[Issue]:
222
222
 
223
223
 
224
224
  def check_worktree_orphan(workspace: Workspace) -> list[Issue]:
225
- """Worktree directories under .canopy/worktrees/ not referenced by any feature."""
225
+ """Worktree directories under .canopy/worktrees/ not referenced by any feature.
226
+
227
+ Pre-3.0 layout only (``<feature>/<repo>``). The Wave-3.0 slot layout
228
+ (``worktree-N/<repo>``) is owned by the ``slot_*`` checks — skip those
229
+ dirs here, or this check would flag every warm slot as an orphan and
230
+ ``--fix`` would delete it.
231
+ """
232
+ import re
226
233
  wt_root = workspace.config.root / ".canopy" / "worktrees"
227
234
  if not wt_root.exists():
228
235
  return []
@@ -231,6 +238,8 @@ def check_worktree_orphan(workspace: Workspace) -> list[Issue]:
231
238
  for feat_dir in sorted(wt_root.iterdir()):
232
239
  if not feat_dir.is_dir():
233
240
  continue
241
+ if re.fullmatch(r"worktree-\d+", feat_dir.name):
242
+ continue # slot dir — handled by check_slot_* functions
234
243
  feature_name = feat_dir.name
235
244
  feature_data = features.get(feature_name)
236
245
  feature_repos = (feature_data or {}).get("repos") or []
@@ -604,6 +613,66 @@ def check_slot_branch_mismatches(workspace: Workspace) -> list[Issue]:
604
613
  return issues
605
614
 
606
615
 
616
+ def check_slot_repo_worktree_missing(workspace: Workspace) -> list[Issue]:
617
+ """A slot holds feature F, but one of F's repos has no worktree on disk.
618
+
619
+ This is the per-repo divergence the other slot checks can't see:
620
+ ``slot_entry_orphan`` only inspects the ``worktree-N/`` top dir (which
621
+ survives as long as ANY repo's subdir remains), and
622
+ ``slot_branch_mismatch`` ``continue``s past a non-existent per-repo path.
623
+ A half-materialized slot bricked canopy-test (``switch`` then tried to
624
+ allocate a fresh slot for an already-occupied feature → ``no_free_slot``).
625
+
626
+ Auto-fixable by recreating the worktree from the feature's branch —
627
+ unless the branch itself is gone, in which case ``branches_missing``
628
+ owns the deeper problem and this is advice-only.
629
+ """
630
+ from . import slots as slots_mod
631
+ from .aliases import repos_for_feature
632
+
633
+ state = slots_mod.read_state(workspace)
634
+ if state is None:
635
+ return []
636
+ issues: list[Issue] = []
637
+ for sid, entry in state.slots.items():
638
+ repo_branches = repos_for_feature(workspace, entry.feature) or {}
639
+ for repo_name, expected_branch in repo_branches.items():
640
+ slot_path = slots_mod.slot_worktree_path(workspace, sid, repo_name)
641
+ if (slot_path / ".git").exists():
642
+ continue
643
+ try:
644
+ rs = workspace.get_repo(repo_name)
645
+ except KeyError:
646
+ continue # features_unknown_repo owns this
647
+ branch_ok = rs.abs_path.exists() and git.branch_exists(
648
+ rs.abs_path, expected_branch,
649
+ )
650
+ issues.append(Issue(
651
+ code="slot_repo_worktree_missing",
652
+ severity="error",
653
+ what=(
654
+ f"slot '{sid}' is missing its '{repo_name}' worktree"
655
+ f" (feature '{entry.feature}', branch '{expected_branch}')"
656
+ ),
657
+ expected=str(slot_path),
658
+ actual="(no worktree on disk)",
659
+ repo=repo_name,
660
+ feature=entry.feature,
661
+ fix_action=(
662
+ f"recreate: git worktree add {slot_path} {expected_branch}"
663
+ if branch_ok else
664
+ f"branch '{expected_branch}' is gone in {repo_name} —"
665
+ f" restore it (see branches_missing) before recreating"
666
+ ),
667
+ auto_fixable=branch_ok,
668
+ details={
669
+ "slot": sid, "feature": entry.feature, "repo": repo_name,
670
+ "branch": expected_branch, "slot_path": str(slot_path),
671
+ },
672
+ ))
673
+ return issues
674
+
675
+
607
676
  # ── Install-staleness checks ─────────────────────────────────────────────
608
677
 
609
678
 
@@ -895,6 +964,7 @@ _CHECKS: dict[str, tuple[str, Any]] = {
895
964
  "vsix_duplicates": ("vsix", check_vsix_duplicates),
896
965
  "slot_dir_orphan": ("slots", check_slot_dir_orphans),
897
966
  "slot_entry_orphan": ("slots", check_slot_entry_orphans),
967
+ "slot_repo_worktree_missing": ("slots", check_slot_repo_worktree_missing),
898
968
  "slot_branch_mismatch": ("slots", check_slot_branch_mismatches),
899
969
  # slot_detached_head shares its check function with slot_branch_mismatch
900
970
  # (one walker emits both codes). The registry entry uses a sentinel
@@ -1276,6 +1346,43 @@ def repair_slot_entry_orphan(workspace: Workspace, issue: Issue) -> RepairResult
1276
1346
  action_taken=f"dropped slots.json entry for '{sid}'")
1277
1347
 
1278
1348
 
1349
+ def repair_slot_repo_worktree_missing(workspace: Workspace, issue: Issue) -> RepairResult:
1350
+ """Recreate the missing per-repo worktree from the feature's branch.
1351
+
1352
+ Restores the slot invariant rather than dropping the slot entry —
1353
+ dropping it would orphan the slot's surviving repos. Idempotent: a no-op
1354
+ if the worktree reappeared.
1355
+ """
1356
+ d = issue.details or {}
1357
+ repo_name, branch, slot_path_s = d.get("repo"), d.get("branch"), d.get("slot_path")
1358
+ if not (repo_name and branch and slot_path_s):
1359
+ return RepairResult(code=issue.code, success=False, action_taken="",
1360
+ error="missing repo/branch/slot_path on issue")
1361
+ slot_path = Path(slot_path_s)
1362
+ if (slot_path / ".git").exists():
1363
+ return RepairResult(code=issue.code, success=True, repo=repo_name,
1364
+ feature=issue.feature,
1365
+ action_taken="worktree already present")
1366
+ try:
1367
+ rs = workspace.get_repo(repo_name)
1368
+ except KeyError as e:
1369
+ return RepairResult(code=issue.code, success=False, action_taken="",
1370
+ error=str(e), repo=repo_name)
1371
+ repo_root = git.worktree_main_path(rs.abs_path) or rs.abs_path
1372
+ slot_path.parent.mkdir(parents=True, exist_ok=True)
1373
+ try:
1374
+ # Prune first so a stale registration for this path doesn't block add.
1375
+ git.worktree_prune(repo_root)
1376
+ git.worktree_add(repo_root, slot_path, branch, create_branch=False)
1377
+ except git.GitError as e:
1378
+ return RepairResult(code=issue.code, success=False, repo=repo_name,
1379
+ feature=issue.feature, action_taken="",
1380
+ error=str(e))
1381
+ return RepairResult(code=issue.code, success=True, repo=repo_name,
1382
+ feature=issue.feature,
1383
+ action_taken=f"git worktree add {slot_path} {branch}")
1384
+
1385
+
1279
1386
  _REPAIRS: dict[str, Any] = {
1280
1387
  "heads_stale": repair_heads_stale,
1281
1388
  "active_feature_orphan": repair_active_feature_orphan,
@@ -1291,6 +1398,7 @@ _REPAIRS: dict[str, Any] = {
1291
1398
  "mcp_orphans": repair_mcp_orphans,
1292
1399
  "vsix_duplicates": repair_vsix_duplicates,
1293
1400
  "slot_entry_orphan": repair_slot_entry_orphan,
1401
+ "slot_repo_worktree_missing": repair_slot_repo_worktree_missing,
1294
1402
  # cli_stale, mcp_stale, features_unknown_repo, branches_missing,
1295
1403
  # slot_dir_orphan, slot_branch_mismatch have no auto-fix —
1296
1404
  # repair returns surfaced advice via the issue's `fix_action` instead.
@@ -34,6 +34,17 @@ from .aliases import resolve_feature, repos_for_feature
34
34
  from .errors import BlockerError, FixAction
35
35
 
36
36
 
37
+ # Precondition BlockerError codes raised by _do_repo_switch BEFORE any git
38
+ # mutation in the failing repo. When one fires with no prior repo completed,
39
+ # the workspace is NOT partially flipped, so no in_flight marker is warranted.
40
+ _PRE_MUTATION_CODES = frozenset({
41
+ "no_free_slot",
42
+ "unknown_slot",
43
+ "evict_to_occupied",
44
+ "warm_worktree_dirty_on_promote",
45
+ })
46
+
47
+
37
48
  def switch(
38
49
  workspace: Workspace,
39
50
  feature: str | None = None,
@@ -158,15 +169,20 @@ def switch(
158
169
  evict_to=evict_to,
159
170
  )
160
171
  except BlockerError as e:
161
- # Even a structured precondition failure (e.g. dirty warm
162
- # worktree on the second repo) can leave disk partially
163
- # mutated by earlier repos. Persist an in_flight marker so
164
- # the next switch refuses to operate on a lie.
165
- _persist_in_flight(
166
- workspace, feature_name, previously_canonical,
167
- failed_repo=repo_name, error_what=e.what or str(e),
168
- completed_results=per_repo_results,
169
- )
172
+ # A structured precondition failure raised BEFORE any git
173
+ # mutation (no_free_slot, dirty warm worktree, bad --evict-to)
174
+ # leaves disk untouched in this repo. Only stamp in_flight when
175
+ # something is actually half-flipped: an earlier repo already
176
+ # completed (per_repo_results non-empty), OR this was a mid-op
177
+ # failure (not one of the pre-mutation precondition codes).
178
+ # Otherwise a clean no-op failure would brick every future
179
+ # switch via slot_state_inconsistent.
180
+ if per_repo_results or e.code not in _PRE_MUTATION_CODES:
181
+ _persist_in_flight(
182
+ workspace, feature_name, previously_canonical,
183
+ failed_repo=repo_name, error_what=e.what or str(e),
184
+ completed_results=per_repo_results,
185
+ )
170
186
  raise
171
187
  except Exception as e:
172
188
  # Mid-op failure with no rollback walker (yet). Surface enough
@@ -281,9 +297,14 @@ def _do_repo_switch(
281
297
  per_repo_results.append(result)
282
298
  return
283
299
  # Fall through: Y's slot entry exists but this repo's slot
284
- # dir is missing (partial-scope drift). Treat as cold-Y.
285
-
286
- # Cold-Y path: allocate a fresh slot for X
300
+ # dir is missing (orphaned worktree / partial-scope drift).
301
+ # Y is still being promoted to canonical, so it vacates its
302
+ # slot regardless X reclaims y_slot below instead of needing
303
+ # a brand-new allocation. Allocating blindly here would count
304
+ # Y's own about-to-be-freed slot against the cap and raise a
305
+ # bogus no_free_slot (the canopy-test billing-export lock-out).
306
+
307
+ # Cold-Y path: allocate (or reclaim) the slot X will occupy.
287
308
  state = slots_mod.read_state(workspace) or slots_mod.SlotState(
288
309
  slot_count=workspace.config.slots,
289
310
  )
@@ -304,6 +325,10 @@ def _do_repo_switch(
304
325
  details={"slot": evict_to, "occupant": existing},
305
326
  )
306
327
  x_slot = evict_to
328
+ elif y_slot is not None:
329
+ # Y already owns y_slot and is leaving it (becoming canonical);
330
+ # X reclaims it. _post_switch_persist re-stamps the slot to X.
331
+ x_slot = y_slot
307
332
  else:
308
333
  x_slot = slots_mod.allocate_slot(state)
309
334
  if x_slot is None:
@@ -118,15 +118,10 @@ def cmd_init(args: argparse.Namespace) -> None:
118
118
  if args.json:
119
119
  all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")]
120
120
  skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
121
- # Detect existing feature worktrees
122
- worktrees_dir = root / ".canopy" / "worktrees"
123
- active_worktrees = {}
124
- if worktrees_dir.is_dir():
125
- for feat_dir in worktrees_dir.iterdir():
126
- if feat_dir.is_dir():
127
- active_worktrees[feat_dir.name] = sorted(
128
- d.name for d in feat_dir.iterdir() if d.is_dir()
129
- )
121
+ # Detect existing worktrees, keyed by FEATURE (slot dirs resolve their
122
+ # occupant via slots.json not reported as if the slot id were a feature).
123
+ from ..workspace.discovery import summarize_worktree_dirs
124
+ active_worktrees = summarize_worktree_dirs(root)
130
125
  _print_json({
131
126
  "root": str(root),
132
127
  "repos": [{
@@ -203,22 +198,15 @@ def cmd_init(args: argparse.Namespace) -> None:
203
198
  console.print(f" mcp [muted]· {note}[/]")
204
199
  console.print(f" [muted]Restart Claude Code to pick up the skill + MCP. Skip with --no-agent.[/]")
205
200
 
206
- # Report existing feature worktrees under .canopy/
207
- canopy_dir = root / ".canopy"
208
- worktrees_dir = canopy_dir / "worktrees"
209
- if worktrees_dir.is_dir():
210
- features_with_wt = sorted(
211
- d.name for d in worktrees_dir.iterdir() if d.is_dir()
212
- )
213
- if features_with_wt:
214
- console.print()
215
- console.print(f" [header]Active worktrees ({len(features_with_wt)})[/]")
216
- for feat in features_with_wt:
217
- feat_dir = worktrees_dir / feat
218
- wt_repos = sorted(
219
- d.name for d in feat_dir.iterdir() if d.is_dir()
220
- )
221
- console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}")
201
+ # Report existing worktrees under .canopy/, keyed by feature (slot dirs
202
+ # resolve their occupant via slots.json — see summarize_worktree_dirs).
203
+ from ..workspace.discovery import summarize_worktree_dirs
204
+ worktrees = summarize_worktree_dirs(root)
205
+ if worktrees:
206
+ console.print()
207
+ console.print(f" [header]Active worktrees ({len(worktrees)})[/]")
208
+ for feat, wt_repos in sorted(worktrees.items()):
209
+ console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}")
222
210
  console.print()
223
211
 
224
212
 
@@ -314,6 +314,7 @@ class FeatureCoordinator:
314
314
  linear_issue=data.get("linear_issue", ""),
315
315
  linear_title=data.get("linear_title", ""),
316
316
  linear_url=data.get("linear_url", ""),
317
+ branches=dict(data.get("branches") or {}),
317
318
  )
318
319
  else:
319
320
  # Implicit feature
@@ -438,7 +439,10 @@ class FeatureCoordinator:
438
439
  continue
439
440
 
440
441
  try:
441
- changes = git.changed_files_with_status(scan_path, name, base)
442
+ # Per-repo branch override: scan the lane's actual branch
443
+ # for this repo, not the bare feature name.
444
+ branch = lane.branch_for(repo_name)
445
+ changes = git.changed_files_with_status(scan_path, branch, base)
442
446
  result[repo_name] = {
443
447
  "has_branch": True,
444
448
  "path": str(scan_path),
@@ -550,7 +554,12 @@ class FeatureCoordinator:
550
554
  continue
551
555
 
552
556
  base = state.config.default_branch
553
- has_branch = git.branch_exists(state.abs_path, lane.name)
557
+ # Honor per-repo branch overrides — the branch may differ from
558
+ # the feature name (FeatureLane.branches map). Using lane.name
559
+ # here would mis-report mismatched-naming features as having no
560
+ # branch / no changes.
561
+ branch = lane.branch_for(repo_name)
562
+ has_branch = git.branch_exists(state.abs_path, branch)
554
563
 
555
564
  if not has_branch:
556
565
  lane.repo_states[repo_name] = {
@@ -564,10 +573,10 @@ class FeatureCoordinator:
564
573
 
565
574
  try:
566
575
  ahead, behind = git.divergence(
567
- state.abs_path, lane.name, base
576
+ state.abs_path, branch, base
568
577
  )
569
- files = git.changed_files(state.abs_path, lane.name, base)
570
- dirty = state.is_dirty if state.current_branch == lane.name else False
578
+ files = git.changed_files(state.abs_path, branch, base)
579
+ dirty = state.is_dirty if state.current_branch == branch else False
571
580
 
572
581
  repo_state: dict = {
573
582
  "has_branch": True,
@@ -580,7 +589,7 @@ class FeatureCoordinator:
580
589
  }
581
590
 
582
591
  # Check if branch is checked out in a worktree
583
- wt_path = git.worktree_for_branch(state.abs_path, lane.name)
592
+ wt_path = git.worktree_for_branch(state.abs_path, branch)
584
593
  if wt_path:
585
594
  repo_state["worktree_path"] = wt_path
586
595
 
@@ -1220,8 +1229,10 @@ class FeatureCoordinator:
1220
1229
  try:
1221
1230
  repo_name = repo_dir.name
1222
1231
  state = self.workspace.get_repo(repo_name)
1232
+ # Per-repo branch override (else the feature name).
1233
+ branch = (meta.get("branches") or {}).get(repo_name, feat_name)
1223
1234
  ahead, _ = git.divergence(
1224
- repo_dir, feat_name, state.config.default_branch,
1235
+ repo_dir, branch, state.config.default_branch,
1225
1236
  )
1226
1237
  if ahead > 0:
1227
1238
  all_merged = False
@@ -732,6 +732,15 @@ def worktree_remove(repo_path: Path, worktree_path: Path, force: bool = False) -
732
732
  return _run(args, cwd=repo_path)
733
733
 
734
734
 
735
+ def worktree_prune(repo_path: Path) -> str:
736
+ """Prune worktree registrations whose directories are gone.
737
+
738
+ Clears stale ``.git/worktrees/<name>`` entries so a subsequent
739
+ ``worktree add`` at the same path isn't rejected as already-registered.
740
+ """
741
+ return _run(["worktree", "prune"], cwd=repo_path)
742
+
743
+
735
744
  def worktree_move(main_repo: Path, old_path: Path, new_path: Path) -> None:
736
745
  """Run `git worktree move <old_path> <new_path>` from main_repo.
737
746
 
@@ -1534,14 +1534,10 @@ def workspace_reinit(name: str | None = None, dry_run: bool = False) -> dict:
1534
1534
  d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")
1535
1535
  ]
1536
1536
  skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
1537
- worktrees_dir = root / ".canopy" / "worktrees"
1538
- active_worktrees: dict[str, list[str]] = {}
1539
- if worktrees_dir.is_dir():
1540
- for feat_dir in worktrees_dir.iterdir():
1541
- if feat_dir.is_dir():
1542
- active_worktrees[feat_dir.name] = sorted(
1543
- d.name for d in feat_dir.iterdir() if d.is_dir()
1544
- )
1537
+ # Keyed by FEATURE slot dirs resolve their occupant via slots.json
1538
+ # rather than being reported as if the slot id were a feature name.
1539
+ from ..workspace.discovery import summarize_worktree_dirs
1540
+ active_worktrees: dict[str, list[str]] = summarize_worktree_dirs(root)
1545
1541
 
1546
1542
  return {
1547
1543
  "root": str(root),
@@ -195,3 +195,44 @@ def _guess_role(name: str, lang: str) -> str:
195
195
  return "frontend"
196
196
 
197
197
  return ""
198
+
199
+
200
+ def summarize_worktree_dirs(root: Path) -> dict[str, list[str]]:
201
+ """Map feature name → repo subdirs present in its worktree slot.
202
+
203
+ Used by ``canopy init`` / ``workspace_reinit`` to report existing
204
+ worktrees. Wave 3.0 worktree dirs are generic numbered SLOTS
205
+ (``worktree-N``) whose occupant feature lives in slots.json — so a slot
206
+ id must be resolved to its feature, not reported AS the feature. Pre-3.0
207
+ dirs are feature-named and map directly. An orphan slot (dir present, no
208
+ occupant in slots.json) falls back to the slot id as the key.
209
+ """
210
+ import json
211
+ import re
212
+
213
+ wt_root = root / ".canopy" / "worktrees"
214
+ if not wt_root.is_dir():
215
+ return {}
216
+
217
+ slot_feature: dict[str, str | None] = {}
218
+ state_path = root / ".canopy" / "state" / "slots.json"
219
+ if state_path.exists():
220
+ try:
221
+ data = json.loads(state_path.read_text())
222
+ for sid, entry in (data.get("slots") or {}).items():
223
+ if isinstance(entry, dict):
224
+ slot_feature[sid] = entry.get("feature")
225
+ except (OSError, ValueError):
226
+ pass
227
+
228
+ out: dict[str, list[str]] = {}
229
+ for d in sorted(wt_root.iterdir()):
230
+ if not d.is_dir():
231
+ continue
232
+ repos = sorted(r.name for r in d.iterdir() if r.is_dir())
233
+ if re.fullmatch(r"worktree-\d+", d.name):
234
+ key = slot_feature.get(d.name) or d.name # feature, else slot id
235
+ else:
236
+ key = d.name # pre-3.0 feature-named dir
237
+ out[key] = repos
238
+ return out
@@ -208,3 +208,20 @@ def test_bootstrap_feature_blocks_when_no_worktrees(workspace_with_bootstrap_con
208
208
  with pytest.raises(BlockerError) as e:
209
209
  bootstrap_feature(workspace_with_bootstrap_config, "auth-flow")
210
210
  assert e.value.code == "no_worktrees"
211
+
212
+
213
+ def test_resolve_worktree_paths_uses_slots_json_for_warm_feature(workspace_with_slots):
214
+ """Wave 3.0: a warm feature's worktree paths come from its SLOT, not the
215
+ legacy features.json worktree_paths cache (which is empty in 3.0). Before
216
+ this fix, bootstrap raised no_worktrees for every warm 3.0 feature."""
217
+ from canopy.actions.bootstrap import _resolve_worktree_paths
218
+ from canopy.actions import slots as sm
219
+
220
+ ws = workspace_with_slots # Y warm in worktree-1 (repo-a + repo-b)
221
+ sid = sm.slot_for_feature(ws, "Y")
222
+ assert sid is not None
223
+
224
+ paths = _resolve_worktree_paths(ws, "Y")
225
+ assert set(paths) == {"repo-a", "repo-b"}
226
+ assert paths["repo-a"] == sm.slot_worktree_path(ws, sid, "repo-a")
227
+ assert (paths["repo-a"] / ".git").exists()