canopy-cli 3.1.0__py3-none-any.whl

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 (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,775 @@
1
+ """switch — the canonical-slot focus primitive.
2
+
3
+ `switch(Y)` promotes Y to the canonical slot (main checkout). Whatever was
4
+ canonical before either:
5
+
6
+ - **Active rotation (default)**: evacuates to a warm worktree at
7
+ ``.canopy/worktrees/<previous>/<repo>/`` so it stays close at hand.
8
+ - **Wind-down (``release_current=True``)**: goes cold (just the branch +
9
+ a feature-tagged stash if there were dirty changes). Use when the
10
+ previous focus is parked / finished and Y is the new focus.
11
+
12
+ Per-repo recipe per mode is in ``evacuate.py`` (active-rotation) and
13
+ inline below (wind-down). Cap-reached failures surface via
14
+ ``switch_preflight.py`` as a structured ``BlockerError`` with explicit
15
+ fix actions — no silent eviction.
16
+
17
+ PR1 scope: the canonical-slot behavior end-to-end with preflight as the
18
+ primary safety net. PR2 adds journal + rollback walker for the residual
19
+ mid-op failures. PR3 adds the fast-path 3-checkout swap when both X and
20
+ Y already have homes.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from ..git import repo as git
29
+ from ..workspace.workspace import Workspace
30
+ from . import evacuate as evac
31
+ from . import slots as slots_mod
32
+ from . import switch_preflight as preflight
33
+ from .aliases import resolve_feature, repos_for_feature
34
+ from .errors import BlockerError, FixAction
35
+
36
+
37
+ def switch(
38
+ workspace: Workspace,
39
+ feature: str | None = None,
40
+ *,
41
+ release_current: bool = False,
42
+ no_evict: bool = False,
43
+ evict: str | None = None,
44
+ evict_to: str | None = None,
45
+ to_slot: str | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Promote ``feature`` to the canonical slot.
48
+
49
+ Args:
50
+ feature: feature alias (resolved via the alias layer). Accepts a
51
+ fresh name too — branches are created from default if missing.
52
+ release_current: wind-down mode. Previously-canonical feature goes
53
+ cold (just stashed if dirty), no warm worktree created.
54
+ no_evict: in active-rotation mode, refuse to evict an LRU warm
55
+ worktree when the cap would fire. Returns a cap-reached
56
+ BlockerError instead. Default False (canopy auto-picks LRU).
57
+ evict: explicit feature name to evict from warm to cold instead of
58
+ the LRU pick. Used when the user wants control after a
59
+ cap-reached blocker surfaced an LRU candidate.
60
+ evict_to: pin which slot the previously-canonical feature evacuates
61
+ to (overrides the LRU-coldest-free pick).
62
+ to_slot: promote the feature currently in this slot to canonical.
63
+ Sugar over ``switch(<feature-in-slot>)``.
64
+
65
+ Returns ``{feature, mode, per_repo_paths, previously_canonical?,
66
+ evacuation?, eviction?, branches_created?, migration?}``.
67
+ """
68
+ # to_slot: resolve the occupant and forward
69
+ if to_slot is not None:
70
+ if feature is not None:
71
+ raise BlockerError(
72
+ code="ambiguous_switch_args",
73
+ what="pass `feature` OR `--to-slot`, not both",
74
+ )
75
+ occupant = slots_mod.feature_for_slot(workspace, to_slot)
76
+ if occupant is None:
77
+ raise BlockerError(
78
+ code="slot_empty",
79
+ what=f"slot '{to_slot}' is empty — nothing to promote",
80
+ details={"slot": to_slot},
81
+ )
82
+ feature = occupant
83
+
84
+ if feature is None:
85
+ raise BlockerError(
86
+ code="missing_feature",
87
+ what="switch needs a feature name or --to-slot",
88
+ )
89
+
90
+ _ensure_post_migration(workspace)
91
+ _ensure_consistent(workspace)
92
+ feature_name = resolve_feature_safely(workspace, feature)
93
+
94
+ repo_branches = repos_for_feature(workspace, feature_name)
95
+ if not repo_branches:
96
+ # Permit fresh feature names (will create branches from default)
97
+ repo_branches = {r.config.name: feature_name for r in workspace.repos}
98
+
99
+ pre = preflight.preflight(
100
+ workspace, feature_name, repo_branches,
101
+ release_current=release_current,
102
+ no_evict=no_evict and (evict is None),
103
+ evict_to=evict_to,
104
+ )
105
+
106
+ out: dict[str, Any] = {"feature": feature_name}
107
+ previously_canonical = pre["previously_canonical"]
108
+ if previously_canonical:
109
+ out["previously_canonical"] = previously_canonical
110
+
111
+ # Step A: optional eviction (active-rotation cap fire) —
112
+ # explicit ``evict=<feature>`` overrides preflight's LRU pick.
113
+ # When ``evict_to`` is pinned to an occupied slot, evict that slot's
114
+ # occupant first so X can land there.
115
+ eviction_info: dict[str, Any] | None = None
116
+ eviction_target: str | None = None
117
+ if not release_current:
118
+ if evict:
119
+ eviction_target = evict
120
+ elif evict_to is not None:
121
+ # Pinned destination — if it holds an occupant other than Y, evict it.
122
+ cur_state = slots_mod.read_state(workspace)
123
+ if cur_state is not None and evict_to in cur_state.slots:
124
+ occ = cur_state.slots[evict_to].feature
125
+ if occ and occ != feature_name:
126
+ eviction_target = occ
127
+ elif pre["cap_will_fire"] and pre["lru_eviction_candidate"]:
128
+ eviction_target = pre["lru_eviction_candidate"]
129
+ if eviction_target:
130
+ eviction_info = _evict_warm_to_cold(workspace, eviction_target)
131
+ out["eviction"] = eviction_info
132
+
133
+ # Step B: branches that need creating from default
134
+ if pre["branches_to_create"]:
135
+ out["branches_created"] = _create_missing_branches(
136
+ workspace, pre["branches_to_create"],
137
+ )
138
+
139
+ # Step C: per-repo per-mode work
140
+ per_repo_results: list[dict[str, Any]] = []
141
+ new_canonical_paths: dict[str, str] = {}
142
+
143
+ for repo_name, target_branch in repo_branches.items():
144
+ try:
145
+ state = workspace.get_repo(repo_name)
146
+ except KeyError:
147
+ continue
148
+ repo_path = state.abs_path
149
+
150
+ try:
151
+ _do_repo_switch(
152
+ workspace, feature_name, repo_name, target_branch,
153
+ repo_path=repo_path,
154
+ release_current=release_current,
155
+ previously_canonical=previously_canonical,
156
+ per_repo_results=per_repo_results,
157
+ new_canonical_paths=new_canonical_paths,
158
+ evict_to=evict_to,
159
+ )
160
+ 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
+ )
170
+ raise
171
+ except Exception as e:
172
+ # Mid-op failure with no rollback walker (yet). Surface enough
173
+ # state for the user to recover manually instead of leaving
174
+ # them with a generic exception. See GitHub issue #2.
175
+ _persist_in_flight(
176
+ workspace, feature_name, previously_canonical,
177
+ failed_repo=repo_name, error_what=str(e),
178
+ completed_results=per_repo_results,
179
+ )
180
+ raise _build_mid_op_error(
181
+ workspace, feature_name, repo_name, target_branch,
182
+ previously_canonical, e, per_repo_results,
183
+ )
184
+
185
+ _post_switch_persist(
186
+ workspace, feature_name, new_canonical_paths, previously_canonical,
187
+ out, release_current=release_current, per_repo_results=per_repo_results,
188
+ )
189
+
190
+ # M4: include the new feature's persistent memory so the agent picks
191
+ # up cross-session context immediately. Empty string when no memory
192
+ # has been recorded yet — caller can ignore.
193
+ from . import historian
194
+ out["memory"] = historian.format_for_agent(
195
+ workspace.config.root, feature_name,
196
+ )
197
+
198
+ return out
199
+
200
+
201
+ def _do_repo_switch(
202
+ workspace: Workspace,
203
+ feature_name: str,
204
+ repo_name: str,
205
+ target_branch: str,
206
+ *,
207
+ repo_path: Path,
208
+ release_current: bool,
209
+ previously_canonical: str | None,
210
+ per_repo_results: list[dict[str, Any]],
211
+ new_canonical_paths: dict[str, str],
212
+ evict_to: str | None = None,
213
+ ) -> None:
214
+ """Per-repo switch body — extracted so the caller can wrap it in a
215
+ structured mid-op error handler. Mutates the lists/dicts in place."""
216
+
217
+ # If main is already on the target branch, nothing to do for this
218
+ # repo aside from recording its path.
219
+ try:
220
+ current = git.current_branch(repo_path)
221
+ except git.GitError:
222
+ current = None
223
+ new_canonical_paths[repo_name] = str(repo_path.resolve())
224
+ if current == target_branch:
225
+ per_repo_results.append({
226
+ "repo": repo_name, "status": "noop",
227
+ "reason": "already on target branch",
228
+ })
229
+ return
230
+
231
+ # Mode A: wind-down — stash X dirty into a feature-tagged stash on
232
+ # X's branch, then plain checkout Y in main. No worktree-add for X.
233
+ if release_current and previously_canonical and current == _branch_for_in_repo(
234
+ workspace, previously_canonical, repo_name,
235
+ ):
236
+ # If Y is warm in a slot, must free it from the slot before main
237
+ # can adopt it (git one-checkout-per-branch rule).
238
+ _free_warm_slot_if_holding(workspace, feature_name, repo_name)
239
+ stash_ref = _stash_for_winddown(
240
+ workspace, previously_canonical, repo_path,
241
+ )
242
+ git.checkout(repo_path, target_branch)
243
+ per_repo_results.append({
244
+ "repo": repo_name, "status": "wind_down_then_checkout",
245
+ "previous_branch": _branch_for_in_repo(
246
+ workspace, previously_canonical, repo_name,
247
+ ),
248
+ "target_branch": target_branch,
249
+ "stashed": stash_ref is not None,
250
+ "stash_ref": stash_ref,
251
+ })
252
+ return
253
+
254
+ # Mode B: active rotation
255
+ if (
256
+ previously_canonical
257
+ and not release_current
258
+ and current == _branch_for_in_repo(
259
+ workspace, previously_canonical, repo_name,
260
+ )
261
+ ):
262
+ # Fast-path: Y is already warm in some slot → 5-op swap
263
+ y_slot = slots_mod.slot_for_feature(workspace, feature_name)
264
+ if y_slot is not None:
265
+ slot_dir = slots_mod.slot_worktree_path(
266
+ workspace, y_slot, repo_name,
267
+ )
268
+ if (slot_dir / ".git").exists():
269
+ default_branch = workspace.get_repo(
270
+ repo_name,
271
+ ).config.default_branch
272
+ result = evac.fastpath_swap_repo(
273
+ workspace,
274
+ x_feature=previously_canonical,
275
+ y_feature=target_branch,
276
+ repo_name=repo_name,
277
+ repo_path=repo_path,
278
+ slot_id=y_slot,
279
+ default_branch=default_branch,
280
+ )
281
+ per_repo_results.append(result)
282
+ return
283
+ # 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
287
+ state = slots_mod.read_state(workspace) or slots_mod.SlotState(
288
+ slot_count=workspace.config.slots,
289
+ )
290
+ if evict_to is not None:
291
+ # Validate the slot id is in range
292
+ valid_slots = {f"worktree-{i}" for i in range(1, workspace.config.slots + 1)}
293
+ if evict_to not in valid_slots:
294
+ raise BlockerError(
295
+ code="unknown_slot",
296
+ what=f"--evict-to {evict_to} is out of range (cap={workspace.config.slots})",
297
+ )
298
+ if evict_to in state.slots:
299
+ existing = state.slots[evict_to].feature
300
+ if existing != feature_name:
301
+ raise BlockerError(
302
+ code="evict_to_occupied",
303
+ what=f"slot '{evict_to}' is already occupied by '{existing}'",
304
+ details={"slot": evict_to, "occupant": existing},
305
+ )
306
+ x_slot = evict_to
307
+ else:
308
+ x_slot = slots_mod.allocate_slot(state)
309
+ if x_slot is None:
310
+ # Preflight should have caught this; defensive
311
+ raise BlockerError(
312
+ code="no_free_slot",
313
+ what="no free slot for evacuation (preflight should have raised)",
314
+ )
315
+ result = evac.evacuate_repo(
316
+ workspace, previously_canonical, repo_name, repo_path,
317
+ slot_id=x_slot,
318
+ target_branch=target_branch,
319
+ )
320
+ per_repo_results.append(result)
321
+ return
322
+
323
+ # Fallback: main is on something else (or not on previous_canonical).
324
+ # Just stash + checkout. If Y happens to be warm somewhere, free it
325
+ # first.
326
+ _free_warm_slot_if_holding(workspace, feature_name, repo_name)
327
+ if git.is_dirty(repo_path):
328
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
329
+ current_label = current or "(detached)"
330
+ git.stash_save(
331
+ repo_path,
332
+ f"[canopy {current_label} @ {ts}] auto-stash on switch",
333
+ include_untracked=True,
334
+ )
335
+ stashed = True
336
+ else:
337
+ stashed = False
338
+ git.checkout(repo_path, target_branch)
339
+ per_repo_results.append({
340
+ "repo": repo_name, "status": "checkout",
341
+ "previous_branch": current,
342
+ "target_branch": target_branch,
343
+ "stashed": stashed,
344
+ })
345
+
346
+
347
+ def _build_mid_op_error(
348
+ workspace: Workspace,
349
+ feature_name: str,
350
+ failed_repo: str,
351
+ target_branch: str,
352
+ previously_canonical: str | None,
353
+ underlying_error: Exception,
354
+ completed_results: list[dict[str, Any]],
355
+ ) -> BlockerError:
356
+ """Build a structured ``BlockerError`` for a mid-op failure.
357
+
358
+ Goal: tell the user exactly which repo failed at which step, what
359
+ state the workspace is in NOW, and the precise commands to recover.
360
+ Without this they get a generic git error and a half-flipped workspace.
361
+
362
+ A real rollback walker is in GitHub issue #2; this is the interim.
363
+ """
364
+ completed_repos = [r["repo"] for r in completed_results]
365
+ # Per-repo recovery hints for completed repos
366
+ recovery_hints: list[str] = []
367
+ for r in completed_results:
368
+ if r.get("stashed"):
369
+ recovery_hints.append(
370
+ f" {r['repo']}: stash exists ({r.get('stash_ref','stash@{0}')}) — "
371
+ f"`git -C <{r['repo']}-path> stash list` to inspect"
372
+ )
373
+ if r.get("status") == "evacuated" and r.get("worktree_path"):
374
+ recovery_hints.append(
375
+ f" {r['repo']}: warm worktree at {r['worktree_path']} (X={previously_canonical})"
376
+ )
377
+
378
+ return BlockerError(
379
+ code="switch_mid_op_failed",
380
+ what=(
381
+ f"switch to '{feature_name}' failed in repo '{failed_repo}' — "
382
+ f"workspace is partially flipped"
383
+ ),
384
+ expected={"feature": feature_name, "target_branch": target_branch},
385
+ actual={
386
+ "failed_repo": failed_repo,
387
+ "completed_repos": completed_repos,
388
+ "underlying_error": str(underlying_error),
389
+ "underlying_error_type": type(underlying_error).__name__,
390
+ },
391
+ details={
392
+ "previously_canonical": previously_canonical,
393
+ "completed_results": completed_results,
394
+ "recovery_hints": recovery_hints,
395
+ },
396
+ fix_actions=[
397
+ FixAction(
398
+ action="manual",
399
+ args={"see": "details.recovery_hints"},
400
+ safe=False,
401
+ preview=(
402
+ "auto-rollback isn't implemented yet (GH #2). "
403
+ "Inspect per-repo state via `canopy state` + `git stash list` "
404
+ "in each repo, then re-run `canopy switch <feature>` once "
405
+ f"the underlying error ({type(underlying_error).__name__}) is resolved."
406
+ ),
407
+ ),
408
+ FixAction(
409
+ action="switch",
410
+ args={"feature": previously_canonical} if previously_canonical else {"feature": feature_name},
411
+ safe=False,
412
+ preview=(
413
+ f"switch back to '{previously_canonical}' may un-flip"
414
+ f" some repos (depends on which step failed)"
415
+ if previously_canonical else "retry the switch"
416
+ ),
417
+ ),
418
+ ],
419
+ )
420
+
421
+
422
+ def _post_switch_persist(
423
+ workspace: Workspace,
424
+ feature_name: str,
425
+ new_canonical_paths: dict[str, str],
426
+ previously_canonical: str | None,
427
+ out: dict[str, Any],
428
+ *,
429
+ release_current: bool,
430
+ per_repo_results: list[dict[str, Any]],
431
+ ) -> None:
432
+ """Finalize the switch result: write ``slots.json`` + populate summary
433
+ fields. Mutates ``out`` in place."""
434
+ out["mode"] = "wind_down" if release_current else "active_rotation"
435
+ out["per_repo"] = per_repo_results
436
+ out["per_repo_paths"] = new_canonical_paths
437
+
438
+ state = slots_mod.read_state(workspace) or slots_mod.SlotState(
439
+ slot_count=workspace.config.slots,
440
+ )
441
+ now = slots_mod.now_iso()
442
+
443
+ state.previous_canonical = (
444
+ state.canonical.feature if state.canonical else None
445
+ )
446
+ state.canonical = slots_mod.CanonicalEntry(
447
+ feature=feature_name,
448
+ activated_at=now,
449
+ per_repo_paths={k: str(v) for k, v in new_canonical_paths.items()},
450
+ )
451
+
452
+ # Apply per-repo slot mutations. fastpath swaps update the existing
453
+ # slot entry; cold-Y evacuations occupy a freshly allocated slot.
454
+ for r in per_repo_results:
455
+ if r.get("status") == "fastpath_swapped":
456
+ sid = r["slot_id"]
457
+ state.slots[sid] = slots_mod.SlotEntry(
458
+ feature=r["swapped_out"], occupied_at=now,
459
+ )
460
+ elif r.get("status") == "evacuated":
461
+ sid = r["slot_id"]
462
+ state.slots[sid] = slots_mod.SlotEntry(
463
+ feature=previously_canonical or "",
464
+ occupied_at=now,
465
+ )
466
+
467
+ state.last_touched[feature_name] = now
468
+ if previously_canonical:
469
+ state.last_touched[previously_canonical] = now
470
+
471
+ # Drop any slot entries that still claim Y — Y is now canonical and
472
+ # its slot dir (if it had one) was emptied by fastpath_swap_repo.
473
+ for sid, entry in list(state.slots.items()):
474
+ if entry.feature == feature_name:
475
+ del state.slots[sid]
476
+
477
+ # Clear any in_flight marker — this switch completed cleanly.
478
+ state.in_flight = None
479
+
480
+ # T14: capture prior anchor BEFORE the T13 bump so the summary's
481
+ # "since when" window reflects what the user last saw, not this visit.
482
+ from . import last_visit as lv
483
+ _prior = lv.get_last_visit(workspace, feature_name)
484
+ prior_iso: str | None = _prior["last_visit"] if _prior else None
485
+
486
+ slots_mod.write_state(workspace, state)
487
+
488
+ # T13: bump last_visit after slots.json is committed — every successful
489
+ # switch into a feature counts as a "conscious look" per the plan.
490
+ lv.mark_visited(workspace, feature_name)
491
+
492
+ # T14: embed since-last-visit summary using the PRIOR anchor.
493
+ try:
494
+ from . import resume
495
+ out["since_last_visit_summary"] = resume.resume_summary(
496
+ workspace, feature_name, prior_iso=prior_iso,
497
+ )
498
+ except Exception:
499
+ out["since_last_visit_summary"] = {
500
+ "last_visit": prior_iso,
501
+ "first_visit": prior_iso is None,
502
+ "new_commit_count": 0,
503
+ "new_thread_count": 0,
504
+ "github_resolved_count": 0,
505
+ "ci_changed": False,
506
+ "draft_replies_pending": 0,
507
+ "memory_present": False,
508
+ "degraded": True,
509
+ }
510
+
511
+ out["activated_at"] = now
512
+ if state.previous_canonical:
513
+ out["previous_feature_in_state"] = state.previous_canonical
514
+
515
+
516
+ def resolve_feature_safely(workspace: Workspace, feature: str) -> str:
517
+ """Like ``resolve_feature`` but accepts a fresh feature name as a
518
+ fallback. Switch is allowed to invent new feature lanes if the user
519
+ types a name that doesn't exist yet."""
520
+ try:
521
+ return resolve_feature(workspace, feature)
522
+ except BlockerError as e:
523
+ if e.code in ("unknown_alias", "ambiguous_alias"):
524
+ return feature
525
+ raise
526
+
527
+
528
+ # ── eviction (warm → cold) ──────────────────────────────────────────────
529
+
530
+ def _evict_warm_to_cold(
531
+ workspace: Workspace, feature: str,
532
+ ) -> dict[str, Any]:
533
+ """Park a warm feature back to cold. Auto-stash any dirty work first.
534
+
535
+ Slot-aware: finds the slot currently holding ``feature`` and clears
536
+ every repo subdir of that slot. The branch stays — feature is now
537
+ cold. After clearing, removes the slot entry from ``slots.json``.
538
+
539
+ Returns ``{feature, slot_id, repos: [{repo, stashed, stash_ref?,
540
+ removed}]}``. Empty repos list if the feature wasn't actually warm.
541
+ """
542
+ slot_id = slots_mod.slot_for_feature(workspace, feature)
543
+ if slot_id is None:
544
+ return {"feature": feature, "slot_id": None, "repos": []}
545
+
546
+ repo_results: list[dict[str, Any]] = []
547
+ for state in workspace.repos:
548
+ repo_name = state.config.name
549
+ wt_path = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
550
+ if not (wt_path.exists() and (wt_path / ".git").exists()):
551
+ continue
552
+ stash_ref: str | None = None
553
+ if git.is_dirty(wt_path):
554
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
555
+ git.stash_save(
556
+ wt_path,
557
+ f"[canopy {feature} @ {ts}] auto-evicted",
558
+ include_untracked=True,
559
+ )
560
+ stash_ref = "stash@{0}"
561
+ git.worktree_remove(state.abs_path, wt_path)
562
+ repo_results.append({
563
+ "repo": repo_name,
564
+ "stashed": stash_ref is not None,
565
+ "stash_ref": stash_ref,
566
+ "removed": True,
567
+ })
568
+
569
+ # Drop the slot entry from state so the slot becomes available.
570
+ st = slots_mod.read_state(workspace)
571
+ if st is not None and slot_id in st.slots:
572
+ del st.slots[slot_id]
573
+ slots_mod.write_state(workspace, st)
574
+ return {"feature": feature, "slot_id": slot_id, "repos": repo_results}
575
+
576
+
577
+ def _free_warm_slot_if_holding(
578
+ workspace: Workspace, feature: str, repo_name: str,
579
+ ) -> None:
580
+ """If ``feature`` is warm in some slot for ``repo_name``, remove that
581
+ slot's worktree for this repo so main can adopt the branch.
582
+
583
+ Raises ``BlockerError(warm_worktree_dirty_on_promote)`` if the slot
584
+ is dirty — losing the user's work is never silent. Mirrors the
585
+ pre-3.0 reverse-evacuation safety check, just keyed by slot id.
586
+ """
587
+ slot_id = slots_mod.slot_for_feature(workspace, feature)
588
+ if slot_id is None:
589
+ return
590
+ wt_path = slots_mod.slot_worktree_path(workspace, slot_id, repo_name)
591
+ if not (wt_path / ".git").exists():
592
+ return
593
+ if git.is_dirty(wt_path):
594
+ raise BlockerError(
595
+ code="warm_worktree_dirty_on_promote",
596
+ what=(
597
+ f"warm worktree {wt_path} has uncommitted changes;"
598
+ f" can't promote {feature} to canonical without losing them"
599
+ ),
600
+ details={"feature": feature, "repo": repo_name,
601
+ "worktree_path": str(wt_path), "slot_id": slot_id},
602
+ fix_actions=[
603
+ FixAction(
604
+ action="commit",
605
+ args={"feature": feature},
606
+ safe=False,
607
+ preview=f"commit dirty changes in {wt_path}",
608
+ ),
609
+ FixAction(
610
+ action="stash_save_feature",
611
+ args={"feature": feature},
612
+ safe=True,
613
+ preview=f"stash dirty changes in {wt_path}",
614
+ ),
615
+ ],
616
+ )
617
+ repo_state = workspace.get_repo(repo_name)
618
+ git.worktree_remove(repo_state.abs_path, wt_path)
619
+
620
+
621
+ # ── wind-down stash helper ──────────────────────────────────────────────
622
+
623
+ def _stash_for_winddown(
624
+ workspace: Workspace, feature: str, repo_path: Path,
625
+ ) -> str | None:
626
+ """Stash dirty work in main for a feature being wound down (cold).
627
+
628
+ Tag matches P12 so future ``switch(feature)`` (warming) auto-finds it.
629
+ """
630
+ if not git.is_dirty(repo_path):
631
+ return None
632
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
633
+ git.stash_save(
634
+ repo_path,
635
+ f"[canopy {feature} @ {ts}] released to cold",
636
+ include_untracked=True,
637
+ )
638
+ return "stash@{0}"
639
+
640
+
641
+ # ── helpers ─────────────────────────────────────────────────────────────
642
+
643
+ def _branch_for_in_repo(
644
+ workspace: Workspace, feature: str, repo_name: str,
645
+ ) -> str:
646
+ """Return the branch name for ``feature`` in ``repo_name``.
647
+
648
+ Honors the lane's ``branches`` map for per-repo branch overrides
649
+ (e.g. auth-flow in api vs auth-flow-v2 in ui)."""
650
+ from ..features.coordinator import FeatureCoordinator
651
+ coord = FeatureCoordinator(workspace)
652
+ try:
653
+ lane = coord.status(feature)
654
+ except Exception:
655
+ return feature
656
+ return lane.branch_for(repo_name)
657
+
658
+
659
+ def _create_missing_branches(
660
+ workspace: Workspace, items: list[tuple[str, str]],
661
+ ) -> list[dict[str, Any]]:
662
+ """Create each missing branch from the repo's default branch.
663
+
664
+ Returns per-repo ``[{repo, branch, base, created_from_sha}]``.
665
+ """
666
+ out = []
667
+ for repo_name, branch in items:
668
+ try:
669
+ state = workspace.get_repo(repo_name)
670
+ except KeyError:
671
+ continue
672
+ base = state.config.default_branch
673
+ base_sha = git.sha_of(state.abs_path, base) or ""
674
+ # --no-track is the right call here (see git/repo.py:create_branch).
675
+ git.create_branch(state.abs_path, branch, start_point=base)
676
+ out.append({
677
+ "repo": repo_name, "branch": branch,
678
+ "base": base, "created_from_sha": base_sha,
679
+ })
680
+ return out
681
+
682
+
683
+ # ── partial-failure marker ──────────────────────────────────────────────
684
+
685
+ def _persist_in_flight(
686
+ workspace: Workspace,
687
+ feature_being_promoted: str,
688
+ previously_canonical: str | None,
689
+ *,
690
+ failed_repo: str,
691
+ error_what: str,
692
+ completed_results: list[dict[str, Any]],
693
+ ) -> None:
694
+ """Stamp ``slots.json`` with an in_flight marker so the next switch
695
+ refuses to run on a half-flipped workspace.
696
+
697
+ Captures: what we were trying to do, what completed before the crash,
698
+ which repo blew up, and the underlying error message. Cleared on the
699
+ next successful switch via ``_post_switch_persist``.
700
+ """
701
+ state = slots_mod.read_state(workspace) or slots_mod.SlotState(
702
+ slot_count=workspace.config.slots,
703
+ )
704
+ state.in_flight = {
705
+ "feature_being_promoted": feature_being_promoted,
706
+ "previously_canonical": previously_canonical,
707
+ "started_at": slots_mod.now_iso(),
708
+ "per_repo_completed": [
709
+ {k: v for k, v in r.items()} for r in completed_results
710
+ ],
711
+ "failed_repo": failed_repo,
712
+ "error_what": error_what,
713
+ }
714
+ slots_mod.write_state(workspace, state)
715
+
716
+
717
+ def _ensure_consistent(workspace: Workspace) -> None:
718
+ """Refuse to switch when an in_flight marker is set.
719
+
720
+ A prior switch left the workspace in a partial state (some repos
721
+ flipped to Y, others still on X). Continuing would compound the
722
+ inconsistency. Surface a structured blocker; T19 will extend doctor
723
+ to actually repair this.
724
+ """
725
+ state = slots_mod.read_state(workspace)
726
+ if state is None or state.in_flight is None:
727
+ return
728
+ inf = state.in_flight
729
+ raise BlockerError(
730
+ code="slot_state_inconsistent",
731
+ what=(
732
+ f"a prior switch to '{inf.get('feature_being_promoted')}' failed in "
733
+ f"repo '{inf.get('failed_repo')}' — workspace is partially flipped"
734
+ ),
735
+ details={"in_flight": dict(inf)},
736
+ fix_actions=[
737
+ FixAction(
738
+ action="doctor",
739
+ args={},
740
+ safe=True,
741
+ preview=(
742
+ "run `canopy doctor` to inspect slots.json and the "
743
+ "completed-vs-failed per-repo work; resolve manually, "
744
+ "then clear the in_flight marker"
745
+ ),
746
+ ),
747
+ ],
748
+ )
749
+
750
+
751
+ # ── pre-3.0 migration gate ──────────────────────────────────────────────
752
+
753
+ def _ensure_post_migration(workspace: Workspace) -> None:
754
+ """Refuse to switch on a workspace still on the pre-3.0 layout.
755
+
756
+ If ``.canopy/state/active_feature.json`` exists, the workspace hasn't
757
+ been migrated to the slot model yet. Surface a structured blocker
758
+ pointing at ``canopy migrate-slots`` instead of silently writing the
759
+ new ``slots.json`` alongside (which would leave two sources of truth).
760
+ """
761
+ old = workspace.config.root / ".canopy/state/active_feature.json"
762
+ if old.exists():
763
+ raise BlockerError(
764
+ code="pre_migration",
765
+ what="this workspace is on the pre-3.0 layout — run `canopy migrate-slots`",
766
+ details={"old_state_file": str(old)},
767
+ fix_actions=[
768
+ FixAction(
769
+ action="migrate_slots",
770
+ args={},
771
+ safe=True,
772
+ preview="canopy migrate-slots — one-shot rewrite to slot layout",
773
+ ),
774
+ ],
775
+ )