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
canopy/mcp/server.py ADDED
@@ -0,0 +1,1797 @@
1
+ """
2
+ Canopy MCP Server — expose workspace operations as MCP tools.
3
+
4
+ Run via stdio:
5
+ canopy-mcp
6
+
7
+ Register in Claude Code / Cursor / etc as an MCP server with:
8
+ {
9
+ "mcpServers": {
10
+ "canopy": {
11
+ "command": "canopy-mcp",
12
+ "env": { "CANOPY_ROOT": "/path/to/workspace" }
13
+ }
14
+ }
15
+ }
16
+
17
+ The CANOPY_ROOT env var tells the server where to find canopy.toml.
18
+ If not set, it uses the current working directory.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+
26
+ from mcp.server.fastmcp import FastMCP
27
+
28
+ from ..workspace.config import load_config, ConfigNotFoundError
29
+ from ..workspace.workspace import Workspace
30
+ from ..workspace.context import detect_context
31
+ from ..features.coordinator import FeatureCoordinator
32
+ from ..git import repo as git
33
+ from ..git import multi
34
+
35
+
36
+ # ── Server setup ─────────────────────────────────────────────────────────
37
+
38
+ mcp = FastMCP(
39
+ "canopy",
40
+ instructions="Workspace-first development orchestrator — coordinates Git across multiple repos. Use CANOPY_ROOT env var to point at the workspace.",
41
+ )
42
+
43
+
44
+ def _get_workspace() -> Workspace:
45
+ """Load workspace from CANOPY_ROOT or cwd."""
46
+ root = os.environ.get("CANOPY_ROOT")
47
+ path = Path(root) if root else None
48
+ try:
49
+ config = load_config(path)
50
+ except ConfigNotFoundError as e:
51
+ raise ValueError(
52
+ f"No canopy.toml found. Set CANOPY_ROOT or run from a canopy workspace. ({e})"
53
+ )
54
+ return Workspace(config)
55
+
56
+
57
+ # ── Meta tools ───────────────────────────────────────────────────────────
58
+
59
+ # Bump when canopy.toml schema changes in a way the extension/agent must know
60
+ # about. Independent of the package version.
61
+ _SCHEMA_VERSION = "1"
62
+
63
+
64
+ @mcp.tool()
65
+ def version() -> dict:
66
+ """Report canopy versions for the doctor handshake.
67
+
68
+ Returns:
69
+ ``{cli_version, mcp_version, schema_version}``. ``cli_version`` is
70
+ the ``canopy`` CLI as resolved from PATH (best-effort; empty string
71
+ if not installed). ``mcp_version`` is the running MCP server's
72
+ package version. ``schema_version`` covers the canopy.toml shape.
73
+ The extension calls this once at startup to compare against its
74
+ bundled expectation; the doctor uses it to detect drift between
75
+ CLI and MCP installations.
76
+ """
77
+ import shutil
78
+ import subprocess
79
+ from .. import __version__
80
+
81
+ cli_version = ""
82
+ cli_path = shutil.which("canopy")
83
+ if cli_path:
84
+ try:
85
+ out = subprocess.run(
86
+ [cli_path, "--version"],
87
+ capture_output=True, text=True, check=False, timeout=5,
88
+ )
89
+ if out.returncode == 0:
90
+ # "canopy 0.1.0" → "0.1.0"
91
+ parts = out.stdout.strip().split()
92
+ cli_version = parts[-1] if parts else ""
93
+ except (OSError, subprocess.TimeoutExpired):
94
+ pass
95
+
96
+ return {
97
+ "cli_version": cli_version,
98
+ "mcp_version": __version__,
99
+ "schema_version": _SCHEMA_VERSION,
100
+ }
101
+
102
+
103
+ # ── Workspace tools ──────────────────────────────────────────────────────
104
+
105
+ @mcp.tool()
106
+ def workspace_status() -> dict:
107
+ """Get the full status of the canopy workspace.
108
+
109
+ Returns repo names, current branches, dirty state, divergence
110
+ from default branch, and active feature lanes. Slot occupancy
111
+ (which feature is in each numbered worktree slot) is tracked
112
+ separately — call ``worktree_info`` for the slot-keyed view.
113
+ """
114
+ ws = _get_workspace()
115
+ ws.refresh()
116
+ return ws.to_dict()
117
+
118
+
119
+ @mcp.tool()
120
+ def workspace_context(cwd: str | None = None) -> dict:
121
+ """Detect canopy context from a directory path.
122
+
123
+ Tells you which feature, repo, and branch you're in based on
124
+ the directory. Useful for understanding worktree structure.
125
+
126
+ Args:
127
+ cwd: Directory to detect from. Defaults to CANOPY_ROOT.
128
+ """
129
+ path = Path(cwd) if cwd else None
130
+ if path is None:
131
+ root = os.environ.get("CANOPY_ROOT")
132
+ path = Path(root) if root else None
133
+ ctx = detect_context(cwd=path)
134
+ return ctx.to_dict()
135
+
136
+
137
+ @mcp.tool()
138
+ def run(repo: str, command: str, feature: str | None = None,
139
+ timeout_seconds: int = 60) -> dict:
140
+ """Run a shell command in a canopy-managed repo, with directory resolution.
141
+
142
+ Eliminates "cd to wrong path" agent mistakes. Pass the repo name and
143
+ canopy resolves the working directory: if ``feature`` is set and a
144
+ worktree exists for ``(feature, repo)``, runs in the worktree;
145
+ otherwise runs in the repo's main path.
146
+
147
+ Returns ``{exit_code, stdout, stderr, cwd, duration_ms}``. Confirm
148
+ ``cwd`` matches your expectation in any post-call reasoning.
149
+
150
+ Args:
151
+ repo: name of the repo as configured in canopy.toml.
152
+ command: shell command to run (e.g. ``"git status"``).
153
+ feature: optional feature name; selects worktree path if applicable.
154
+ timeout_seconds: kills the process after this many seconds (default 60).
155
+ """
156
+ from ..agent.runner import run_in_repo
157
+
158
+ ws = _get_workspace()
159
+ return run_in_repo(ws, repo=repo, command=command, feature=feature,
160
+ timeout_seconds=timeout_seconds)
161
+
162
+
163
+ @mcp.tool()
164
+ def feature_state(feature: str) -> dict:
165
+ """Compute feature state + suggested next actions (dashboard backend).
166
+
167
+ Returns one of: drifted, needs_work, in_progress, ready_to_commit,
168
+ ready_to_push, awaiting_review, approved, no_prs.
169
+
170
+ Composes drift detection (live git, not heads.json), dirty/clean
171
+ state, ahead/behind, temporal-filtered review comments, and recorded
172
+ preflight result into a single state + ordered next_actions list.
173
+ Same logic the dashboard CTAs and the agent both consume.
174
+ """
175
+ from ..actions.feature_state import feature_state as _impl
176
+ ws = _get_workspace()
177
+ return _impl(ws, feature)
178
+
179
+
180
+ @mcp.tool()
181
+ def triage(author: str = "@me", repos: list[str] | None = None) -> dict:
182
+ """Prioritized list of features needing user attention.
183
+
184
+ Enumerates open PRs across all configured repos, groups by feature
185
+ lane (explicit from features.json or implicit by shared branch),
186
+ classifies each via the temporal review-comment filter, and orders
187
+ by priority:
188
+
189
+ changes_requested > review_required_with_bot_comments
190
+ > review_required > approved
191
+
192
+ The agent's morning daily-loop entry point. `author='@me'` filters
193
+ to the authenticated user's PRs (gh CLI shorthand).
194
+ """
195
+ from ..actions.triage import triage as _impl
196
+ ws = _get_workspace()
197
+ return _impl(ws, author=author, repos=repos)
198
+
199
+
200
+ @mcp.tool()
201
+ def switch(feature: str | None = None, release_current: bool = False,
202
+ no_evict: bool = False, evict: str | None = None,
203
+ evict_to: str | None = None, to_slot: str | None = None) -> dict:
204
+ """Promote a feature to the canonical slot (Wave 3.0 canonical-slot model).
205
+
206
+ Worktrees live in numbered slots (``.canopy/worktrees/worktree-N/``),
207
+ not feature-named dirs. Slot identity is stable; feature occupancy is
208
+ transient. If the target feature is already warm in a slot, switch uses
209
+ a fast 5-op path (stash → checkout → pop in each repo). If the target is
210
+ cold, the outgoing feature's slot is reused (or the lowest free slot is
211
+ allocated for it).
212
+
213
+ Two modes for what happens to the previously-canonical feature X:
214
+
215
+ - **Active rotation (default)**: X evacuates to a warm worktree
216
+ slot (stash → worktree-add → pop). Use when X still needs your
217
+ attention soon — switching back is instant.
218
+ - **Wind-down (release_current=True)**: X goes straight to cold
219
+ (just the branch + a feature-tagged stash if there were dirty
220
+ changes). Use when X is parked / finished and Y is the new
221
+ focus.
222
+
223
+ When active-rotation would exceed the warm-slot cap (default 2),
224
+ canopy raises a structured BlockerError(code='worktree_cap_reached')
225
+ with fix_actions: switch in wind-down mode, evict a specific LRU
226
+ warm to cold (with auto-stash), or raise the cap. Use no_evict=True
227
+ to refuse auto-eviction (raises the same blocker for the user to
228
+ decide); use evict='<feature>' to override the LRU pick with a
229
+ specific feature.
230
+
231
+ See docs/concepts.md §4 for the full canonical-slot model.
232
+
233
+ After this, calls without an explicit `feature` argument
234
+ (canopy_run, feature_state, IDE openers) default to this feature.
235
+
236
+ Returns {feature, mode, per_repo_paths, previously_canonical?,
237
+ eviction?, branches_created?, migration?, per_repo, activated_at}
238
+ on success, or a structured ``BlockerError``-shaped dict
239
+ ``{status: "blocked", code, what, fix_actions, ...}`` when a
240
+ precondition refuses the action (e.g. ``worktree_cap_reached``).
241
+ Dashboards inspect ``status`` to render a modal with the fix actions.
242
+ """
243
+ from ..actions.switch import switch as _impl
244
+ from ..actions.errors import ActionError
245
+ ws = _get_workspace()
246
+ try:
247
+ return _impl(
248
+ ws, feature,
249
+ release_current=release_current,
250
+ no_evict=no_evict,
251
+ evict=evict,
252
+ evict_to=evict_to,
253
+ to_slot=to_slot,
254
+ )
255
+ except ActionError as e:
256
+ # Surface BlockerError / FailedError as a structured response so
257
+ # the dashboard can render the cap-reached modal (or any future
258
+ # blocker) without parsing string repr. Same convention used by
259
+ # linear_my_issues + ``feature_state`` warnings.
260
+ return e.to_dict()
261
+
262
+
263
+ @mcp.tool()
264
+ def slot_load(
265
+ feature: str, slot_id: str | None = None,
266
+ replace: bool = False, bootstrap: bool = False,
267
+ ) -> dict:
268
+ """Warm a cold feature into a slot WITHOUT changing canonical.
269
+
270
+ Use `switch` to actually make a feature the active workspace; use
271
+ `slot_load` to pre-warm a slot for fast future switching, or to load
272
+ a feature for inspection (e.g. before review) without disturbing the
273
+ canonical.
274
+
275
+ slot_id defaults to the lowest free slot. Raises worktree_cap_reached
276
+ when all slots are full. With replace=True, evicts the slot's
277
+ current occupant to cold first.
278
+ """
279
+ from ..actions.slot_load import slot_load as _impl
280
+ return _impl(_get_workspace(), feature,
281
+ slot_id=slot_id, replace=replace, bootstrap=bootstrap)
282
+
283
+
284
+ @mcp.tool()
285
+ def slot_clear(slot_id: str) -> dict:
286
+ """Evict the occupant of a slot to cold (with feature-tagged stash if dirty)."""
287
+ from ..actions.slot_load import slot_clear as _impl
288
+ return _impl(_get_workspace(), slot_id)
289
+
290
+
291
+ @mcp.tool()
292
+ def slot_swap(slot_a: str, slot_b: str) -> dict:
293
+ """Exchange the occupants of two slots. Requires identical repo scope on both features."""
294
+ from ..actions.slot_load import slot_swap as _impl
295
+ return _impl(_get_workspace(), slot_a, slot_b)
296
+
297
+
298
+ @mcp.tool()
299
+ def commit(message: str = "", feature: str | None = None,
300
+ repos: list[str] | None = None, paths: list[str] | None = None,
301
+ no_hooks: bool = False, amend: bool = False,
302
+ address: str | None = None,
303
+ resolve_thread: bool | None = None) -> dict:
304
+ """Commit across every repo in a feature lane with a single message (Wave 2.3).
305
+
306
+ Defaults to the canonical feature when ``feature`` is omitted (reads
307
+ ``.canopy/state/slots.json``). ``--paths`` filters staging
308
+ to those files; otherwise stages all tracked changes (``git add -u``).
309
+
310
+ Pre-flight: every in-scope repo must be on the feature's expected
311
+ branch. Mismatches raise ``BlockerError(code='wrong_branch')`` with
312
+ a per-repo expected/actual map; no commits fire.
313
+
314
+ ``address`` (M3): a bot review comment id (numeric or GitHub URL).
315
+ When set, the message is auto-suffixed with the comment title + URL
316
+ and a resolution is recorded in ``.canopy/state/bot_resolutions.json``
317
+ against the matching repo's commit SHA. Non-bot comments raise
318
+ ``BlockerError(code='not_a_bot_comment')``.
319
+
320
+ ``resolve_thread`` (T4): when ``address`` is set, controls whether the
321
+ corresponding GitHub review thread is resolved after a successful commit.
322
+ ``True`` forces resolve; ``False`` forces skip; ``None`` (default) defers
323
+ to the workspace augment ``auto_resolve_threads_on_address``.
324
+
325
+ Per-repo result statuses:
326
+ - ``ok`` — committed; carries ``sha``, ``files_changed``.
327
+ - ``nothing`` — no changes staged.
328
+ - ``hooks_failed`` — pre-commit / commit-msg hook rejected; carries
329
+ tail of ``hook_output``. Other repos continue.
330
+ - ``failed`` — git error (gpg, locked index, etc.).
331
+
332
+ Returns ``{feature, results: {<repo>: {...}}, addressed?}`` on success,
333
+ or a structured ``BlockerError``-shaped dict on pre-flight rejection.
334
+ """
335
+ from ..actions.commit import commit as _impl
336
+ from ..actions.errors import ActionError
337
+ ws = _get_workspace()
338
+ try:
339
+ return _impl(
340
+ ws, message,
341
+ feature=feature, repos=repos, paths=paths,
342
+ no_hooks=no_hooks, amend=amend, address=address,
343
+ resolve_thread=resolve_thread,
344
+ )
345
+ except ActionError as e:
346
+ return e.to_dict()
347
+
348
+
349
+ @mcp.tool()
350
+ def bot_comments_status(feature: str | None = None) -> dict:
351
+ """Per-feature rollup of bot review comments (M3).
352
+
353
+ Returns ``{feature, repos: {<repo>: {pr_number, total, resolved,
354
+ unresolved, threads}}, all_resolved, any_bot_comments}``. Bot threads
355
+ are read live from open PRs; resolutions come from the persistent
356
+ ``.canopy/state/bot_resolutions.json`` log written by
357
+ ``commit --address``.
358
+ """
359
+ from ..actions.bot_status import bot_comments_status as _impl
360
+ from ..actions.errors import ActionError
361
+ ws = _get_workspace()
362
+ try:
363
+ return _impl(ws, feature=feature)
364
+ except ActionError as e:
365
+ return e.to_dict()
366
+
367
+
368
+ # ── Historian (M4) ──────────────────────────────────────────────────────
369
+
370
+
371
+ def _historian_feature(feature: str | None) -> tuple:
372
+ """Resolve (workspace_root, feature_name) for a historian call.
373
+
374
+ Falls back to the canonical feature when ``feature`` is omitted.
375
+ """
376
+ from ..actions import slots as slots_mod
377
+ from ..actions.aliases import resolve_feature
378
+ from ..actions.errors import BlockerError
379
+ ws = _get_workspace()
380
+ if feature:
381
+ return ws.config.root, resolve_feature(ws, feature)
382
+ state = slots_mod.read_state(ws)
383
+ if state is None or state.canonical is None:
384
+ raise BlockerError(
385
+ code="no_canonical_feature",
386
+ what="no active feature; pass `feature` or run `canopy switch <name>` first",
387
+ )
388
+ return ws.config.root, state.canonical.feature
389
+
390
+
391
+ @mcp.tool()
392
+ def historian_decide(feature: str | None = None,
393
+ decisions: list[dict] | None = None) -> dict:
394
+ """Record one or more agent decisions in the feature's memory file (M4).
395
+
396
+ ``decisions`` is a list of ``{"title": str, "rationale": str}`` dicts.
397
+ Decisions are deduped per-session by title — calling the tool twice
398
+ with the same title within a session is a no-op (the hybrid Stop-hook
399
+ backup mechanism relies on this).
400
+ """
401
+ from ..actions import historian
402
+ from ..actions.errors import ActionError
403
+ try:
404
+ root, name = _historian_feature(feature)
405
+ except ActionError as e:
406
+ return e.to_dict()
407
+ out = []
408
+ for d in (decisions or []):
409
+ out.append(historian.record_decision(
410
+ root, name, title=d.get("title", ""), rationale=d.get("rationale", ""),
411
+ ))
412
+ return {"feature": name, "results": out}
413
+
414
+
415
+ @mcp.tool()
416
+ def historian_pause(feature: str | None = None, reason: str = "") -> dict:
417
+ """Record a pause / blocker for the feature (M4)."""
418
+ from ..actions import historian
419
+ from ..actions.errors import ActionError
420
+ try:
421
+ root, name = _historian_feature(feature)
422
+ except ActionError as e:
423
+ return e.to_dict()
424
+ return {"feature": name, **historian.record_pause(root, name, reason=reason)}
425
+
426
+
427
+ @mcp.tool()
428
+ def historian_defer_comment(feature: str | None = None,
429
+ comment_id: str = "", reason: str = "") -> dict:
430
+ """Mark a review comment as intentionally deferred (M4)."""
431
+ from ..actions import historian
432
+ from ..actions.errors import ActionError
433
+ try:
434
+ root, name = _historian_feature(feature)
435
+ except ActionError as e:
436
+ return e.to_dict()
437
+ return {"feature": name, **historian.record_comment_deferred(
438
+ root, name, comment_id=comment_id, reason=reason,
439
+ )}
440
+
441
+
442
+ @mcp.tool()
443
+ def feature_memory(feature: str | None = None) -> dict:
444
+ """Read the rendered feature memory as markdown (M4).
445
+
446
+ Returns ``{feature, memory: <markdown or "">}`` — empty string when
447
+ no memory has been recorded yet.
448
+ """
449
+ from ..actions import historian
450
+ from ..actions.errors import ActionError
451
+ try:
452
+ root, name = _historian_feature(feature)
453
+ except ActionError as e:
454
+ return e.to_dict()
455
+ return {"feature": name, "memory": historian.format_for_agent(root, name)}
456
+
457
+
458
+ @mcp.tool()
459
+ def historian_compact(feature: str | None = None,
460
+ keep_sessions: int = 5) -> dict:
461
+ """Trim the Sessions section to the most-recent ``keep_sessions`` (M4).
462
+
463
+ v1 is mechanical — it drops session entries beyond the cutoff while
464
+ preserving the Resolutions log + PR context entries. A future LLM
465
+ pass can replace this with summarized recaps; the storage shape is
466
+ forward-compatible.
467
+ """
468
+ from ..actions import historian
469
+ from ..actions.errors import ActionError
470
+ try:
471
+ root, name = _historian_feature(feature)
472
+ except ActionError as e:
473
+ return e.to_dict()
474
+ return {"feature": name, **historian.compact(
475
+ root, name, keep_sessions=keep_sessions,
476
+ )}
477
+
478
+
479
+ @mcp.tool()
480
+ def push(feature: str | None = None, repos: list[str] | None = None,
481
+ set_upstream: bool = False, force_with_lease: bool = False,
482
+ dry_run: bool = False) -> dict:
483
+ """Push the feature branch in every in-scope repo (Wave 2.3).
484
+
485
+ Defaults to the canonical feature. Pre-flight raises
486
+ ``BlockerError(code='no_upstream')`` if any in-scope repo lacks an
487
+ upstream and ``set_upstream`` was not passed; the fix-action carries
488
+ the same call args plus ``set_upstream=True`` so the agent retries
489
+ mechanically.
490
+
491
+ Per-repo result statuses:
492
+ - ``ok`` — pushed; carries ``pushed_count``, ``ref``.
493
+ - ``up_to_date`` — branch is already at upstream; nothing to push.
494
+ - ``rejected`` — non-fast-forward without ``force_with_lease``.
495
+ - ``failed`` — git error (network, auth, etc.).
496
+
497
+ Returns ``{feature, results: {<repo>: {...}}}`` on success, or a
498
+ structured ``BlockerError``-shaped dict on pre-flight rejection.
499
+ """
500
+ from ..actions.errors import ActionError
501
+ from ..actions.push import push as _impl
502
+ ws = _get_workspace()
503
+ try:
504
+ return _impl(
505
+ ws,
506
+ feature=feature, repos=repos,
507
+ set_upstream=set_upstream,
508
+ force_with_lease=force_with_lease,
509
+ dry_run=dry_run,
510
+ )
511
+ except ActionError as e:
512
+ return e.to_dict()
513
+
514
+
515
+ @mcp.tool()
516
+ def stash_save_feature(feature: str, message: str = "",
517
+ repos: list[str] | None = None) -> dict:
518
+ """Stash dirty changes (incl. untracked) with a feature tag.
519
+
520
+ Stash message becomes '[canopy <feature> @ <iso_ts>] <message>',
521
+ parseable by stash_list_grouped / stash_pop_feature.
522
+ """
523
+ from ..actions.stash import save_for_feature
524
+ ws = _get_workspace()
525
+ return save_for_feature(ws, feature, message, repos=repos)
526
+
527
+
528
+ @mcp.tool()
529
+ def stash_list_grouped(feature: str | None = None) -> dict:
530
+ """List stashes across repos, grouped by feature tag.
531
+
532
+ Returns {by_feature: {<f>: [...]}, untagged: [...]}. Optional
533
+ `feature` filter scopes to a single feature (untagged excluded).
534
+ """
535
+ from ..actions.stash import list_grouped
536
+ ws = _get_workspace()
537
+ return list_grouped(ws, feature=feature)
538
+
539
+
540
+ @mcp.tool()
541
+ def stash_pop_feature(feature: str, repos: list[str] | None = None) -> dict:
542
+ """Pop the most recent feature-tagged stash per repo."""
543
+ from ..actions.stash import pop_feature
544
+ ws = _get_workspace()
545
+ return pop_feature(ws, feature, repos=repos)
546
+
547
+
548
+ @mcp.tool()
549
+ def linear_get_issue(alias: str) -> dict:
550
+ """Deprecated. Use ``issue_get`` instead.
551
+
552
+ Provider-agnostic alias surviving from the pre-M5 era. Forwards to
553
+ the configured issue provider via ``actions.reads.linear_get_issue``;
554
+ same return shape (``{alias, issue_id, title, state, url, description, raw}``).
555
+ Will be removed in a future release.
556
+
557
+ Accepts:
558
+ - Provider-native issue ID (Linear ``"SIN-7"``, GH ``"#142"``)
559
+ - Feature alias whose lane has a linked issue
560
+ """
561
+ from ..actions.reads import linear_get_issue as _impl
562
+ ws = _get_workspace()
563
+ return _impl(ws, alias)
564
+
565
+
566
+ @mcp.tool()
567
+ def github_get_pr(alias: str) -> dict:
568
+ """Fetch PR data per repo for an alias.
569
+
570
+ Accepts:
571
+ - Feature alias (e.g. 'TEAM-101') -> all PRs in the lane
572
+ - <repo>#<pr_number> (e.g. 'api#142') -> specific PR
573
+ - GitHub PR URL -> specific PR
574
+ """
575
+ from ..actions.reads import github_get_pr as _impl
576
+ ws = _get_workspace()
577
+ return _impl(ws, alias)
578
+
579
+
580
+ @mcp.tool()
581
+ def github_get_branch(alias: str, repo: str | None = None) -> dict:
582
+ """Fetch branch info (HEAD sha, ahead/behind, upstream) per repo.
583
+
584
+ Accepts:
585
+ - Feature alias -> per-repo branches from the feature lane
586
+ - <repo>:<branch> -> specific branch in specific repo
587
+
588
+ Pass `repo` to filter feature-alias results to one repo.
589
+ """
590
+ from ..actions.reads import github_get_branch as _impl
591
+ ws = _get_workspace()
592
+ return _impl(ws, alias, repo=repo)
593
+
594
+
595
+ @mcp.tool()
596
+ def github_get_pr_comments(alias: str) -> dict:
597
+ """Fetch temporally classified PR review comments per repo.
598
+
599
+ Same shape as `review_comments` (per-repo actionable_threads /
600
+ likely_resolved_threads / resolved_thread_count / latest_commit_at)
601
+ but accepts the full alias surface — feature alias, <repo>#<n>, or PR URL.
602
+
603
+ Bot threads are kept; the temporal classifier handles staleness regardless
604
+ of author.
605
+ """
606
+ from ..actions.reads import github_get_pr_comments as _impl
607
+ ws = _get_workspace()
608
+ return _impl(ws, alias)
609
+
610
+
611
+ @mcp.tool()
612
+ def doctor(
613
+ fix: bool = False,
614
+ fix_categories: list[str] | None = None,
615
+ feature: str | None = None,
616
+ clean_vsix: bool = False,
617
+ ) -> dict:
618
+ """Diagnose workspace + install integrity; optionally repair.
619
+
620
+ Returns ``{issues, summary, fixed, skipped, ...}``. ``issues`` is a list
621
+ of typed records ``{code, severity, what, expected?, actual?, repo?,
622
+ feature?, fix_action?, auto_fixable, details?}``. ``summary`` rolls up
623
+ counts by severity. ``fixed`` and ``skipped`` are populated when
624
+ ``fix=True`` (they are empty otherwise).
625
+
626
+ Diagnostic codes (16 categories):
627
+ State-integrity: heads_stale, active_feature_orphan,
628
+ active_feature_path_missing, worktree_orphan, worktree_missing,
629
+ hook_missing, hook_chained_unsafe, preflight_stale,
630
+ features_unknown_repo, branches_missing.
631
+ Install-staleness: cli_stale, mcp_stale, mcp_missing_in_workspace,
632
+ skill_missing, skill_stale, vsix_duplicates.
633
+
634
+ Use this as the recovery entry point when any other canopy operation
635
+ returns an unexpected error — most "something is off" cases trace to
636
+ one of the categories above.
637
+
638
+ Args:
639
+ fix: repair every auto-fixable issue.
640
+ fix_categories: limit ``fix`` to a subset of categories
641
+ (heads, active_feature, worktrees, hooks, preflight, features,
642
+ branches, cli, mcp, skill, vsix). Implies ``fix=True``.
643
+ feature: scope feature-bearing checks to one feature.
644
+ clean_vsix: required to repair ``vsix_duplicates`` (destructive).
645
+ """
646
+ from ..actions.doctor import doctor as _doctor
647
+
648
+ ws = _get_workspace()
649
+ return _doctor(
650
+ ws,
651
+ fix=fix,
652
+ fix_categories=fix_categories,
653
+ feature=feature,
654
+ clean_vsix=clean_vsix,
655
+ )
656
+
657
+
658
+ @mcp.tool()
659
+ def pr_checks(alias: str) -> dict:
660
+ """Fetch CI check runs for a PR alias (M10).
661
+
662
+ ``alias`` is the universal-resolved form: feature alias,
663
+ ``<repo>#<n>``, or PR URL. Returns the rolled-up status plus the raw
664
+ per-check list — useful when the rolled-up ``ci_status`` on
665
+ ``feature_state.repos[*].pr`` isn't enough.
666
+ """
667
+ from ..actions.aliases import resolve_pr_targets
668
+ from ..integrations import github as gh
669
+
670
+ ws = _get_workspace()
671
+ targets = resolve_pr_targets(ws, alias)
672
+ out = []
673
+ for t in targets:
674
+ rollup, raw = gh.get_pr_checks(
675
+ ws.config.root, t.owner, t.repo_slug, t.pr_number,
676
+ )
677
+ out.append({
678
+ "repo": t.repo,
679
+ "pr_number": t.pr_number,
680
+ "ci_status": rollup,
681
+ "checks": raw,
682
+ })
683
+ return {"alias": alias, "results": out}
684
+
685
+
686
+ @mcp.tool()
687
+ def worktree_bootstrap(
688
+ feature: str,
689
+ force: bool = False,
690
+ steps: list[str] | None = None,
691
+ ) -> dict:
692
+ """Bootstrap a feature's worktrees — env-files, deps, IDE workspace (M6).
693
+
694
+ Three optional steps, off by default unless the matching config is
695
+ set in canopy.toml: env-file copy from main checkout into the
696
+ worktree, dep install via per-repo ``install_cmd``, and a
697
+ ``.canopy/workspaces/<feature>.code-workspace`` file when
698
+ ``[workspace] ide = "vscode"`` is set.
699
+
700
+ Args:
701
+ feature: feature alias.
702
+ force: overwrite existing destination env files.
703
+ steps: subset of {"env", "deps", "ide"} to run; default = all three.
704
+ """
705
+ from ..actions.bootstrap import bootstrap_feature
706
+
707
+ ws = _get_workspace()
708
+ return bootstrap_feature(ws, feature, force=force, steps=steps)
709
+
710
+
711
+ @mcp.tool()
712
+ def ship(
713
+ feature: str | None = None,
714
+ repos: list[str] | None = None,
715
+ draft: bool = False,
716
+ reviewers: list[str] | None = None,
717
+ dry_run: bool = False,
718
+ base: str | None = None,
719
+ ) -> dict:
720
+ """Open or update one PR per repo in the canonical feature (M8 / Wave 2.4).
721
+
722
+ Per-repo recipe: ensure-pushed → ensure-PR-exists. Cross-repo body
723
+ refresh runs second so each PR description links to its siblings.
724
+
725
+ Args:
726
+ feature: feature alias. Defaults to the canonical slot.
727
+ repos: optional repo filter within the feature scope.
728
+ draft: open PRs as drafts (initial open only).
729
+ reviewers: GitHub handles to request review from.
730
+ dry_run: enumerate without firing pushes/opens.
731
+ base: override base branch for every repo.
732
+ """
733
+ from ..actions.ship import ship as ship_impl
734
+
735
+ ws = _get_workspace()
736
+ return ship_impl(
737
+ ws, feature=feature, repos=repos, draft=draft,
738
+ reviewers=reviewers, dry_run=dry_run, base=base,
739
+ )
740
+
741
+
742
+ @mcp.tool()
743
+ def draft_replies(alias: str, include_likely_resolved: bool = False) -> dict:
744
+ """Auto-draft "Done in <sha>" replies for addressed PR comments (M9).
745
+
746
+ For each unresolved comment, walk the file's git history since the
747
+ comment was anchored. If anything changed, the comment is
748
+ "addressed" — return a template-based draft the user reviews and
749
+ posts (or edits + posts). No LLM in v1.
750
+
751
+ Args:
752
+ alias: feature name, ``<repo>#<n>``, or PR URL.
753
+ include_likely_resolved: also draft for the temporal classifier's
754
+ ``likely_resolved`` set (weaker signal — surfaced as
755
+ ``confidence: low``).
756
+ """
757
+ from ..actions.draft_replies import draft_replies as draft_impl
758
+
759
+ ws = _get_workspace()
760
+ return draft_impl(ws, alias, include_likely_resolved=include_likely_resolved)
761
+
762
+
763
+ @mcp.tool()
764
+ def conflicts(
765
+ feature: str | None = None,
766
+ other: str | None = None,
767
+ include_cold: bool = False,
768
+ line_level: bool = False,
769
+ ) -> dict:
770
+ """Cross-feature file-overlap detection (M12).
771
+
772
+ Pairwise intersect each active feature's changed-file set per repo
773
+ and surface pairs that touch the same files. ``high`` severity
774
+ means same file (or, when ``line_level=True``, overlapping line
775
+ ranges) — the rebase will conflict; rebase one onto the other
776
+ before opening a PR.
777
+
778
+ Args:
779
+ feature: scope to "what overlaps with this feature." Returns
780
+ only pairs where ``feature`` is one side.
781
+ other: further scope to "specifically <feature> vs <other>."
782
+ Requires ``feature``.
783
+ include_cold: also consider cold features (no worktree). Default
784
+ keeps the focus on actively rotating features.
785
+ line_level: opt into the per-file line-range comparison. Slower
786
+ because it re-runs ``git diff --unified=0`` per repo, but
787
+ lets ``medium`` accurately mean "same file, disjoint lines."
788
+ """
789
+ from ..actions.conflicts import find_conflicts
790
+
791
+ ws = _get_workspace()
792
+ return find_conflicts(
793
+ ws, feature=feature, other=other,
794
+ include_cold=include_cold, line_level=line_level,
795
+ )
796
+
797
+
798
+ @mcp.tool()
799
+ def reply_to_thread(
800
+ thread_id: str,
801
+ body: str,
802
+ feature: str | None = None,
803
+ resolve_after: bool = False,
804
+ ) -> dict:
805
+ """Post a reply to a GH review thread; optionally resolve after.
806
+
807
+ Args:
808
+ thread_id: The GitHub review thread node ID (must start with
809
+ ``PRRT_``).
810
+ body: The reply text to post.
811
+ feature: Feature to attribute the reply to. Defaults to the
812
+ canonical feature if not supplied.
813
+ resolve_after: If True, resolve the thread after posting the reply
814
+ and record the resolution in the canopy log.
815
+ """
816
+ from ..actions.thread_actions import reply_to_thread as _impl
817
+ from ..actions.errors import ActionError
818
+
819
+ ws = _get_workspace()
820
+ try:
821
+ feat = _historian_feature(feature)[1]
822
+ except ActionError as e:
823
+ return e.to_dict()
824
+ try:
825
+ return _impl(ws, thread_id, body, feature=feat, resolve_after=resolve_after)
826
+ except ActionError as e:
827
+ return e.to_dict()
828
+
829
+
830
+ @mcp.tool()
831
+ def resolve_thread(thread_id: str, feature: str | None = None) -> dict:
832
+ """Resolve a GitHub PR review thread and record the resolution locally.
833
+
834
+ Calls the GitHub GraphQL ``resolveReviewThread`` mutation and appends
835
+ an entry to ``.canopy/state/thread_resolutions.json`` so the resume
836
+ brief can distinguish threads closed by canopy from those resolved
837
+ directly on GitHub.
838
+
839
+ Args:
840
+ thread_id: The GitHub review thread node ID (must start with
841
+ ``PRRT_``).
842
+ feature: Feature to attribute the resolution to. Defaults to the
843
+ canonical feature if not supplied.
844
+ """
845
+ from ..actions.thread_actions import resolve_thread as _impl
846
+ from ..actions.errors import ActionError
847
+
848
+ ws = _get_workspace()
849
+ try:
850
+ feat = _historian_feature(feature)[1]
851
+ except ActionError as e:
852
+ return e.to_dict()
853
+ try:
854
+ return _impl(ws, thread_id, feature=feat)
855
+ except ActionError as e:
856
+ return e.to_dict()
857
+
858
+
859
+ @mcp.tool()
860
+ def feature_resume(alias: str) -> dict:
861
+ """Fresh "what changed since last visit" brief.
862
+
863
+ Refreshes GitHub + Linear on every call. Returns:
864
+ - since_last_visit: commits, new threads, resolved threads (GH + canopy),
865
+ ci status delta (v1: empty), draft_replies_pending, historian_excerpt
866
+ - current_state: feature_state, ci_summary_per_repo, bot_unresolved_total,
867
+ draft_replies_summary, branch_position_per_repo, linear link
868
+ - first_visit, last_visit, window_hours
869
+ - switch_performed, switch_summary
870
+ - intent_hints (prioritized next actions)
871
+
872
+ The agent should call this on first activity in a feature per session
873
+ (or after returning from another feature). Switch already embeds a
874
+ counts-only summary; this is the full payload.
875
+
876
+ Args:
877
+ alias: Feature name, Linear ID, PR URL, or slot ID.
878
+ """
879
+ from ..actions.resume import feature_resume as _impl
880
+ from ..actions.errors import ActionError
881
+
882
+ ws = _get_workspace()
883
+ try:
884
+ return _impl(ws, alias)
885
+ except ActionError as e:
886
+ return e.to_dict()
887
+
888
+
889
+ @mcp.tool()
890
+ def drift(feature: str | None = None) -> dict:
891
+ """Compare recorded HEAD state vs feature lane expectations.
892
+
893
+ Returns a structured report of which feature lanes are aligned (all
894
+ repos on the expected branch) vs drifted (one or more repos on a
895
+ different branch, or repos with no recorded HEAD state yet).
896
+
897
+ Use this as the precondition check before any multi-repo write op
898
+ (commit, push, ship). If any feature shows drift, the agent should
899
+ surface it and offer to run ``switch`` to re-align the canonical slot.
900
+
901
+ Args:
902
+ feature: limit the report to one feature lane. If None, all
903
+ active feature lanes are reported.
904
+ """
905
+ from ..actions.drift import detect_drift
906
+
907
+ ws = _get_workspace()
908
+ ws.refresh()
909
+ return detect_drift(ws, feature_name=feature).to_dict()
910
+
911
+
912
+ # ── Feature lane tools ───────────────────────────────────────────────────
913
+
914
+ @mcp.tool()
915
+ def feature_create(
916
+ name: str,
917
+ repos: list[str] | None = None,
918
+ use_worktrees: bool = False,
919
+ ) -> dict:
920
+ """Create a new feature lane across repos.
921
+
922
+ Creates matching git branches (and optionally worktrees) in all
923
+ or specified repos in the workspace.
924
+
925
+ Args:
926
+ name: Feature/branch name (e.g. "auth-flow").
927
+ repos: Subset of repo names. Default: all repos.
928
+ use_worktrees: If true, create linked worktrees so each repo
929
+ gets its own directory under .canopy/worktrees/<name>/.
930
+ """
931
+ ws = _get_workspace()
932
+ coordinator = FeatureCoordinator(ws)
933
+ lane = coordinator.create(name, repos, use_worktrees=use_worktrees)
934
+
935
+ result = lane.to_dict()
936
+ if use_worktrees:
937
+ result["worktree_paths"] = coordinator.resolve_paths(name)
938
+ return result
939
+
940
+
941
+ @mcp.tool()
942
+ def feature_list() -> list[dict]:
943
+ """List all active feature lanes with their repo states.
944
+
945
+ Shows both explicitly created features and implicit ones
946
+ (branches that exist in 2+ repos).
947
+ """
948
+ ws = _get_workspace()
949
+ coordinator = FeatureCoordinator(ws)
950
+ return [lane.to_dict() for lane in coordinator.list_active()]
951
+
952
+
953
+ @mcp.tool()
954
+ def feature_status(name: str) -> dict:
955
+ """Get detailed status for a feature lane.
956
+
957
+ Shows per-repo branch state: ahead/behind default, dirty files,
958
+ changed files, and worktree paths if applicable.
959
+
960
+ Args:
961
+ name: Feature lane name.
962
+ """
963
+ ws = _get_workspace()
964
+ coordinator = FeatureCoordinator(ws)
965
+ lane = coordinator.status(name)
966
+ return lane.to_dict()
967
+
968
+
969
+ @mcp.tool()
970
+ def feature_diff(name: str) -> dict:
971
+ """Get aggregate diff for a feature lane across all repos.
972
+
973
+ Shows files changed, insertions, deletions per repo, plus
974
+ cross-repo type overlap detection.
975
+
976
+ Args:
977
+ name: Feature lane name.
978
+ """
979
+ ws = _get_workspace()
980
+ coordinator = FeatureCoordinator(ws)
981
+ return coordinator.diff(name)
982
+
983
+
984
+ @mcp.tool()
985
+ def feature_changes(name: str) -> dict:
986
+ """Per-file change status (M/A/D/?) for each repo in a feature.
987
+
988
+ Includes uncommitted changes — uses the worktree path when one exists
989
+ so the listing matches what the user is actively editing.
990
+
991
+ Args:
992
+ name: Feature lane name.
993
+ """
994
+ ws = _get_workspace()
995
+ coordinator = FeatureCoordinator(ws)
996
+ return coordinator.feature_changes(name)
997
+
998
+
999
+ @mcp.tool()
1000
+ def feature_merge_readiness(name: str) -> dict:
1001
+ """Check if a feature lane is ready to merge.
1002
+
1003
+ Checks: all repos clean, branches up to date with default,
1004
+ no type overlaps across repos.
1005
+
1006
+ Args:
1007
+ name: Feature lane name.
1008
+ """
1009
+ ws = _get_workspace()
1010
+ coordinator = FeatureCoordinator(ws)
1011
+ return coordinator.merge_readiness(name)
1012
+
1013
+
1014
+ @mcp.tool()
1015
+ def feature_paths(name: str) -> dict:
1016
+ """Get working directory paths for each repo in a feature lane.
1017
+
1018
+ Returns the best path per repo: worktree path if it exists,
1019
+ repo path if the branch is checked out there, etc.
1020
+
1021
+ Args:
1022
+ name: Feature lane name.
1023
+ """
1024
+ ws = _get_workspace()
1025
+ coordinator = FeatureCoordinator(ws)
1026
+ return coordinator.resolve_paths(name)
1027
+
1028
+
1029
+ # ── Git operations ───────────────────────────────────────────────────────
1030
+
1031
+ @mcp.tool()
1032
+ def checkout(branch: str, repos: list[str] | None = None) -> dict:
1033
+ """Checkout a branch across workspace repos.
1034
+
1035
+ Args:
1036
+ branch: Branch name to checkout.
1037
+ repos: Subset of repo names. Default: all repos.
1038
+ """
1039
+ ws = _get_workspace()
1040
+ results = multi.checkout_all(ws, branch, repos)
1041
+ return {"branch": branch, "results": {k: str(v) for k, v in results.items()}}
1042
+
1043
+
1044
+
1045
+ @mcp.tool()
1046
+ def preflight(cwd: str | None = None) -> dict:
1047
+ """Context-aware pre-commit quality gate.
1048
+
1049
+ Detects which feature/repos you're in from the directory path,
1050
+ stages all changes (git add -A), and runs pre-commit hooks.
1051
+ Does NOT commit — reports whether the code is ready to commit.
1052
+
1053
+ Args:
1054
+ cwd: Directory to detect context from. Defaults to CANOPY_ROOT.
1055
+ """
1056
+ from ..integrations.precommit import run_precommit
1057
+ from ..actions.augments import repo_augments
1058
+ from ..workspace.config import load_config, ConfigNotFoundError, ConfigError
1059
+
1060
+ path = Path(cwd) if cwd else None
1061
+ if path is None:
1062
+ root = os.environ.get("CANOPY_ROOT")
1063
+ path = Path(root) if root else None
1064
+ ctx = detect_context(cwd=path)
1065
+
1066
+ if not ctx.repo_paths:
1067
+ return {"error": "No repos found in context", "context": ctx.to_dict()}
1068
+
1069
+ workspace_config = None
1070
+ if ctx.workspace_root:
1071
+ try:
1072
+ workspace_config = load_config(ctx.workspace_root)
1073
+ except (ConfigNotFoundError, ConfigError):
1074
+ workspace_config = None
1075
+
1076
+ results = {}
1077
+ all_passed = True
1078
+
1079
+ for repo_path, repo_name in zip(ctx.repo_paths, ctx.repo_names):
1080
+ status = git.status_porcelain(repo_path)
1081
+ if not status:
1082
+ results[repo_name] = {"status": "clean", "hooks": None}
1083
+ continue
1084
+ try:
1085
+ git._run(["add", "-A"], cwd=repo_path)
1086
+ except git.GitError as e:
1087
+ results[repo_name] = {"status": "error", "error": str(e), "hooks": None}
1088
+ all_passed = False
1089
+ continue
1090
+
1091
+ augments = (
1092
+ repo_augments(workspace_config, repo_name) if workspace_config else None
1093
+ )
1094
+ hook_result = run_precommit(repo_path, augments=augments)
1095
+ passed = hook_result["passed"]
1096
+ if not passed:
1097
+ all_passed = False
1098
+
1099
+ dirty_count = len(status.strip().splitlines())
1100
+ results[repo_name] = {
1101
+ "status": "staged" if passed else "hooks_failed",
1102
+ "dirty_count": dirty_count,
1103
+ "hooks": hook_result,
1104
+ }
1105
+
1106
+ return {
1107
+ "feature": ctx.feature,
1108
+ "context_type": ctx.context_type,
1109
+ "all_passed": all_passed,
1110
+ "results": results,
1111
+ }
1112
+
1113
+
1114
+ @mcp.tool()
1115
+ def log(max_count: int = 20, feature: str | None = None) -> list[dict]:
1116
+ """Get interleaved commit log across all repos, sorted by date.
1117
+
1118
+ Args:
1119
+ max_count: Maximum entries to return.
1120
+ feature: If set, show log for this feature branch.
1121
+ """
1122
+ ws = _get_workspace()
1123
+ return multi.log_all(ws, max_count=max_count, feature=feature)
1124
+
1125
+
1126
+ @mcp.tool()
1127
+ def branch_list() -> dict:
1128
+ """List all local branches across workspace repos.
1129
+
1130
+ Returns per-repo branch lists with current branch, sha, and subject.
1131
+ """
1132
+ ws = _get_workspace()
1133
+ return multi.branches_all(ws)
1134
+
1135
+
1136
+ @mcp.tool()
1137
+ def branch_delete(
1138
+ name: str,
1139
+ force: bool = False,
1140
+ repos: list[str] | None = None,
1141
+ ) -> dict:
1142
+ """Delete a branch across workspace repos.
1143
+
1144
+ Args:
1145
+ name: Branch name to delete.
1146
+ force: Force delete even if not fully merged.
1147
+ repos: Subset of repo names. Default: all repos.
1148
+ """
1149
+ ws = _get_workspace()
1150
+ return multi.delete_branch_all(ws, name, force=force, repos=repos)
1151
+
1152
+
1153
+ @mcp.tool()
1154
+ def branch_rename(
1155
+ old_name: str,
1156
+ new_name: str,
1157
+ repos: list[str] | None = None,
1158
+ ) -> dict:
1159
+ """Rename a branch across workspace repos.
1160
+
1161
+ Args:
1162
+ old_name: Current branch name.
1163
+ new_name: New branch name.
1164
+ repos: Subset of repo names. Default: all repos.
1165
+ """
1166
+ ws = _get_workspace()
1167
+ return multi.rename_branch_all(ws, old_name, new_name, repos=repos)
1168
+
1169
+
1170
+ # ── Stash tools ──────────────────────────────────────────────────────────
1171
+
1172
+ @mcp.tool()
1173
+ def stash_save(
1174
+ message: str = "",
1175
+ repos: list[str] | None = None,
1176
+ ) -> dict:
1177
+ """Stash uncommitted changes across workspace repos.
1178
+
1179
+ Args:
1180
+ message: Optional stash message.
1181
+ repos: Subset of repo names. Default: all repos.
1182
+ """
1183
+ ws = _get_workspace()
1184
+ return multi.stash_save_all(ws, message=message, repos=repos)
1185
+
1186
+
1187
+ @mcp.tool()
1188
+ def stash_pop(
1189
+ index: int = 0,
1190
+ repos: list[str] | None = None,
1191
+ ) -> dict:
1192
+ """Pop stash across workspace repos.
1193
+
1194
+ Args:
1195
+ index: Stash index to pop.
1196
+ repos: Subset of repo names. Default: all repos.
1197
+ """
1198
+ ws = _get_workspace()
1199
+ return multi.stash_pop_all(ws, index=index, repos=repos)
1200
+
1201
+
1202
+ @mcp.tool()
1203
+ def stash_list() -> dict:
1204
+ """List stash entries across all workspace repos."""
1205
+ ws = _get_workspace()
1206
+ return multi.stash_list_all(ws)
1207
+
1208
+
1209
+ @mcp.tool()
1210
+ def stash_drop(
1211
+ index: int = 0,
1212
+ repos: list[str] | None = None,
1213
+ ) -> dict:
1214
+ """Drop a stash entry across workspace repos.
1215
+
1216
+ Args:
1217
+ index: Stash index to drop.
1218
+ repos: Subset of repo names. Default: all repos.
1219
+ """
1220
+ ws = _get_workspace()
1221
+ return multi.stash_drop_all(ws, index=index, repos=repos)
1222
+
1223
+
1224
+ # ── Worktree tools ──────────────────────────────────────────────────────
1225
+
1226
+ @mcp.tool()
1227
+ def worktree_info() -> dict:
1228
+ """Get live worktree status across the workspace — always fresh.
1229
+
1230
+ Wave 3.0: worktrees live in numbered slots (``.canopy/worktrees/
1231
+ worktree-N/<repo>/``), not feature-named directories. Slot identity
1232
+ is stable across switches; feature occupancy is transient. Reads
1233
+ slot state from ``.canopy/state/slots.json`` and enriches each
1234
+ slot's repo subdirs with live git state (branch, dirty files,
1235
+ ahead/behind).
1236
+
1237
+ Returns:
1238
+ slots: slot-keyed map ``{worktree-N: {feature, repos}}`` where
1239
+ each repo entry has branch, dirty, dirty_count, dirty_files,
1240
+ ahead, behind, default_branch, and path.
1241
+ repos: per-repo git worktree list from the main working tree.
1242
+ """
1243
+ ws = _get_workspace()
1244
+ coordinator = FeatureCoordinator(ws)
1245
+ return coordinator.worktrees_live()
1246
+
1247
+
1248
+ @mcp.tool()
1249
+ def worktree_create(
1250
+ name: str,
1251
+ issue: str | None = None,
1252
+ repos: list[str] | None = None,
1253
+ ) -> dict:
1254
+ """Create a feature with worktrees, optionally linked to a Linear issue.
1255
+
1256
+ Wave 3.0: worktrees live in numbered slots (``.canopy/worktrees/
1257
+ worktree-N/<repo>/``), not feature-named directories. The allocated
1258
+ slot ID (e.g. ``worktree-1``) is returned as ``slot_id`` so callers
1259
+ can reference the slot directly.
1260
+
1261
+ This is the primary workflow entry point: create isolated worktree
1262
+ directories for each repo, open them in your IDE, and optionally
1263
+ link to a Linear issue for tracking.
1264
+
1265
+ Args:
1266
+ name: Feature/branch name (e.g. "payment-flow").
1267
+ issue: Optional Linear issue ID (e.g. "ENG-123"). If a Linear
1268
+ MCP server is configured in .canopy/mcps.json, fetches the
1269
+ issue title and URL. The issue ID is stored in feature
1270
+ metadata either way.
1271
+ repos: Subset of repo names. Default: all repos.
1272
+
1273
+ Returns:
1274
+ Lane dict with ``slot_id`` (the allocated worktree-N slot) and
1275
+ ``worktree_paths`` (per-repo absolute paths inside that slot).
1276
+ When ``issue`` was passed, also includes
1277
+ ``linear_lookup: {status, reason?}`` so the agent sees whether
1278
+ the Linear fetch succeeded:
1279
+
1280
+ - ``ok`` — title/url populated.
1281
+ - ``not_configured`` — no linear entry in mcps.json; lane has
1282
+ just the issue ID.
1283
+ - ``failed`` — Linear MCP responded but the fetch errored or
1284
+ returned an empty title/url (often a tool-arg schema
1285
+ mismatch); ``reason`` carries the detail.
1286
+ """
1287
+ ws = _get_workspace()
1288
+ coordinator = FeatureCoordinator(ws)
1289
+
1290
+ linear_issue = ""
1291
+ linear_title = ""
1292
+ linear_url = ""
1293
+ linear_lookup: dict = {"status": "skipped"}
1294
+
1295
+ if issue:
1296
+ from ..integrations.linear import (
1297
+ is_linear_configured,
1298
+ get_issue,
1299
+ LinearNotConfiguredError,
1300
+ LinearIssueNotFoundError,
1301
+ )
1302
+ from .client import McpClientError
1303
+
1304
+ if is_linear_configured(ws.config.root):
1305
+ try:
1306
+ issue_data = get_issue(ws.config.root, issue)
1307
+ linear_issue = issue_data.get("identifier", issue)
1308
+ linear_title = issue_data.get("title", "")
1309
+ linear_url = issue_data.get("url", "")
1310
+ if linear_title or linear_url:
1311
+ linear_lookup = {"status": "ok"}
1312
+ else:
1313
+ # get_issue returned but with no title/url — treat as
1314
+ # a lookup failure so the agent sees the lane was
1315
+ # created with just the bare issue ID.
1316
+ linear_lookup = {
1317
+ "status": "failed",
1318
+ "reason": (
1319
+ "Linear MCP responded but returned no title or URL"
1320
+ " (likely a tool-arg schema mismatch)"
1321
+ ),
1322
+ }
1323
+ linear_issue = issue
1324
+ linear_title = ""
1325
+ linear_url = ""
1326
+ except LinearIssueNotFoundError as e:
1327
+ linear_lookup = {"status": "failed", "reason": f"issue not found: {e}"}
1328
+ linear_issue = issue
1329
+ except (LinearNotConfiguredError, McpClientError) as e:
1330
+ linear_lookup = {"status": "failed", "reason": str(e)}
1331
+ linear_issue = issue
1332
+ else:
1333
+ linear_lookup = {"status": "not_configured"}
1334
+ linear_issue = issue
1335
+
1336
+ from ..features.coordinator import WorktreeLimitError
1337
+ try:
1338
+ lane = coordinator.create(
1339
+ name,
1340
+ repos=repos,
1341
+ use_worktrees=True,
1342
+ linear_issue=linear_issue,
1343
+ linear_title=linear_title,
1344
+ linear_url=linear_url,
1345
+ )
1346
+ except WorktreeLimitError as e:
1347
+ return {
1348
+ "error": "worktree_limit_reached",
1349
+ "message": str(e),
1350
+ "current": e.current,
1351
+ "limit": e.limit,
1352
+ "stale_candidates": e.stale,
1353
+ }
1354
+
1355
+ result = lane.to_dict()
1356
+ result["worktree_paths"] = coordinator.resolve_paths(name)
1357
+ from ..actions import slots as _slots_mod
1358
+ slot_id = _slots_mod.slot_for_feature(ws, name)
1359
+ if slot_id is not None:
1360
+ result["slot_id"] = slot_id
1361
+ if issue:
1362
+ result["linear_lookup"] = linear_lookup
1363
+ return result
1364
+
1365
+
1366
+ # ── Feature done ────────────────────────────────────────────────────────
1367
+
1368
+ @mcp.tool()
1369
+ def feature_done(feature: str, force: bool = False) -> dict:
1370
+ """Clean up a completed feature: remove worktrees, delete branches, archive.
1371
+
1372
+ Use this when a feature is merged or abandoned. It removes worktree
1373
+ directories, deletes local branches, and marks the feature as 'done'
1374
+ in features.json. Does not touch remotes or PRs.
1375
+
1376
+ Fails if worktrees have uncommitted changes unless force=True.
1377
+
1378
+ Args:
1379
+ feature: Feature lane name.
1380
+ force: If True, remove even with dirty worktrees.
1381
+ """
1382
+ ws = _get_workspace()
1383
+ coordinator = FeatureCoordinator(ws)
1384
+ return coordinator.done(feature, force=force)
1385
+
1386
+
1387
+ # ── Config tools ────────────────────────────────────────────────────────
1388
+
1389
+ @mcp.tool()
1390
+ def workspace_config(
1391
+ key: str | None = None,
1392
+ value: str | None = None,
1393
+ ) -> dict:
1394
+ """Read or write workspace settings in canopy.toml.
1395
+
1396
+ With no arguments: returns all settings.
1397
+ With key only: returns that setting's value.
1398
+ With key and value: sets the value and returns it.
1399
+
1400
+ Available settings: name, slots.
1401
+
1402
+ Args:
1403
+ key: Setting name (e.g. "slots").
1404
+ value: New value to set. Omit to read.
1405
+ """
1406
+ from ..workspace.config import (
1407
+ get_config_value, set_config_value, get_all_config,
1408
+ WORKSPACE_SETTINGS,
1409
+ )
1410
+
1411
+ root = _get_workspace().config.root
1412
+
1413
+ if key is None:
1414
+ return get_all_config(root)
1415
+
1416
+ if value is None:
1417
+ v = get_config_value(root, key)
1418
+ return {"key": key, "value": v}
1419
+
1420
+ coerced = set_config_value(root, key, value)
1421
+ return {"key": key, "value": coerced}
1422
+
1423
+
1424
+ # ── Review tools ────────────────────────────────────────────────────────
1425
+
1426
+ @mcp.tool()
1427
+ def review_status(feature: str) -> dict:
1428
+ """Check if pull requests exist for a feature across repos.
1429
+
1430
+ For each repo in the feature lane, resolves the GitHub remote and
1431
+ checks for an open PR matching the feature branch. Requires a
1432
+ GitHub MCP server configured in .canopy/mcps.json.
1433
+
1434
+ Args:
1435
+ feature: Feature lane name (e.g. "auth-flow").
1436
+
1437
+ Returns:
1438
+ Per-repo PR status including number, title, URL. The top-level
1439
+ "has_prs" field is False if no PRs exist in any repo — the
1440
+ review workflow cannot proceed without PRs.
1441
+ """
1442
+ ws = _get_workspace()
1443
+ coordinator = FeatureCoordinator(ws)
1444
+ return coordinator.review_status(feature)
1445
+
1446
+
1447
+ @mcp.tool()
1448
+ def review_comments(feature: str) -> dict:
1449
+ """Fetch unresolved PR review comments for a feature across repos.
1450
+
1451
+ Requires an open PR in at least one repo — fails if no PRs exist.
1452
+ Returns comments grouped by repo and file, filtered to unresolved
1453
+ comments only (resolved and bot comments are excluded).
1454
+
1455
+ This is the primary tool for an agent to understand what reviewers
1456
+ want changed before the PR can be merged.
1457
+
1458
+ Args:
1459
+ feature: Feature lane name (e.g. "auth-flow").
1460
+
1461
+ Returns:
1462
+ Comments grouped by repo, each with path, line, body, author.
1463
+ total_comments gives the aggregate count across all repos.
1464
+ """
1465
+ ws = _get_workspace()
1466
+ coordinator = FeatureCoordinator(ws)
1467
+ return coordinator.review_comments(feature)
1468
+
1469
+
1470
+ @mcp.tool()
1471
+ def review_prep(
1472
+ feature: str,
1473
+ message: str = "",
1474
+ ) -> dict:
1475
+ """Run pre-commit hooks and stage all changes for a feature.
1476
+
1477
+ This is the "get to commit-ready state" workflow:
1478
+ 1. Finds working directories for the feature (worktrees or repos)
1479
+ 2. Runs pre-commit hooks in each repo (detects framework vs git hooks)
1480
+ 3. Stages all changes (git add -A)
1481
+ 4. Reports per-repo results
1482
+
1483
+ Does NOT create a commit — it leaves the repos staged and ready.
1484
+ Call the `commit` tool afterwards to actually commit.
1485
+
1486
+ Args:
1487
+ feature: Feature lane name.
1488
+ message: Suggested commit message (included in result for
1489
+ convenience, not used for committing).
1490
+
1491
+ Returns:
1492
+ Per-repo pre-commit results and staging status.
1493
+ all_passed is True only if every repo's hooks passed.
1494
+ """
1495
+ ws = _get_workspace()
1496
+ coordinator = FeatureCoordinator(ws)
1497
+ return coordinator.review_prep(feature, message=message)
1498
+
1499
+
1500
+ # ── Workspace lifecycle ──────────────────────────────────────────────────
1501
+
1502
+ @mcp.tool()
1503
+ def workspace_reinit(name: str | None = None, dry_run: bool = False) -> dict:
1504
+ """Re-run Canopy's repo/worktree discovery and regenerate canopy.toml.
1505
+
1506
+ Useful when repos or worktrees have been added/removed outside Canopy.
1507
+ Always overwrites canopy.toml (no-op check is the caller's job). Set
1508
+ `dry_run=True` to preview the new TOML without writing.
1509
+
1510
+ Args:
1511
+ name: Override the workspace name (defaults to the existing one or
1512
+ the directory basename).
1513
+ dry_run: If True, return the new TOML without writing.
1514
+
1515
+ Returns:
1516
+ { root, repos, skipped, active_worktrees, toml, written }
1517
+ """
1518
+ from ..workspace.discovery import discover_repos, generate_toml
1519
+
1520
+ root = Path(os.environ.get("CANOPY_ROOT") or os.getcwd()).resolve()
1521
+ repos = discover_repos(root)
1522
+ if not repos:
1523
+ raise ValueError(f"No Git repositories found in {root}")
1524
+
1525
+ toml_content = generate_toml(root, workspace_name=name)
1526
+
1527
+ toml_path = root / "canopy.toml"
1528
+ written = False
1529
+ if not dry_run:
1530
+ toml_path.write_text(toml_content)
1531
+ written = True
1532
+
1533
+ all_dirs = [
1534
+ d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")
1535
+ ]
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
+ )
1545
+
1546
+ return {
1547
+ "root": str(root),
1548
+ "repos": [
1549
+ {
1550
+ "name": r.name,
1551
+ "path": r.path,
1552
+ "role": r.role,
1553
+ "lang": r.lang,
1554
+ "is_worktree": r.is_worktree,
1555
+ "worktree_main": r.worktree_main,
1556
+ }
1557
+ for r in repos
1558
+ ],
1559
+ "skipped": skipped,
1560
+ "active_worktrees": active_worktrees,
1561
+ "toml": toml_content,
1562
+ "written": written,
1563
+ }
1564
+
1565
+
1566
+ # ── Issue providers ──────────────────────────────────────────────────────
1567
+
1568
+
1569
+ @mcp.tool()
1570
+ def issue_get(alias: str) -> dict:
1571
+ """Fetch an issue from the workspace's configured issue provider.
1572
+
1573
+ Routes through the provider registry (M5). The workspace's
1574
+ ``[issue_provider]`` block in canopy.toml selects the backend
1575
+ (Linear / GitHub Issues / future). Aliases are provider-native:
1576
+ Linear ``"SIN-7"``, GitHub ``"#142"`` or ``"owner/repo#142"``.
1577
+
1578
+ Returns the canonical Issue dict (id, identifier, title, description,
1579
+ state, url, assignee, labels, priority, raw).
1580
+
1581
+ On not-configured / not-found / call-failed: returns a structured
1582
+ BlockerError dict so the agent can react programmatically.
1583
+ """
1584
+ from ..providers import (
1585
+ IssueNotFoundError, ProviderNotConfigured, IssueProviderError,
1586
+ get_issue_provider,
1587
+ )
1588
+ from ..actions.aliases import resolve_issue_id
1589
+ from ..actions.errors import ActionError, BlockerError, FixAction
1590
+ ws = _get_workspace()
1591
+ try:
1592
+ # Resolve through the M5 alias layer so feature names + provider-
1593
+ # native ids both work: SIN-412 / 5 / #5 / owner/repo#5 / URL /
1594
+ # auth-flow (looks up linked issue id from features.json).
1595
+ try:
1596
+ resolved = resolve_issue_id(ws, alias)
1597
+ except ActionError as err:
1598
+ return err.to_dict()
1599
+ provider = get_issue_provider(ws)
1600
+ issue = provider.get_issue(resolved)
1601
+ except ProviderNotConfigured as e:
1602
+ return BlockerError(
1603
+ code="issue_provider_not_configured",
1604
+ what=f"Issue provider '{ws.config.issue_provider.name}' is not configured",
1605
+ details={"alias": alias, "error": str(e)},
1606
+ fix_actions=[
1607
+ FixAction(
1608
+ action="configure_provider",
1609
+ args={"provider": ws.config.issue_provider.name},
1610
+ safe=True,
1611
+ preview=f"configure {ws.config.issue_provider.name} per docs/architecture/providers.md §4",
1612
+ ),
1613
+ ],
1614
+ ).to_dict()
1615
+ except IssueNotFoundError as e:
1616
+ return BlockerError(
1617
+ code="issue_not_found",
1618
+ what=f"Issue '{alias}' not found",
1619
+ details={"alias": alias, "error": str(e)},
1620
+ ).to_dict()
1621
+ except IssueProviderError as e:
1622
+ return BlockerError(
1623
+ code="issue_provider_failed",
1624
+ what=f"Issue provider call failed",
1625
+ details={"alias": alias, "error": str(e)},
1626
+ ).to_dict()
1627
+ return issue.to_dict()
1628
+
1629
+
1630
+ @mcp.tool()
1631
+ def issue_list_my_issues(limit: int = 25) -> list[dict] | dict:
1632
+ """List the current user's open issues from the configured provider.
1633
+
1634
+ Returns ``[]`` when the provider isn't configured (no autocomplete
1635
+ available). Returns a structured BlockerError-shaped dict when the
1636
+ provider IS configured but the call failed.
1637
+
1638
+ Args:
1639
+ limit: Maximum issues to return (default 25).
1640
+ """
1641
+ from ..providers import (
1642
+ IssueProviderError, ProviderNotConfigured, get_issue_provider,
1643
+ )
1644
+ from ..actions.errors import BlockerError, FixAction
1645
+ ws = _get_workspace()
1646
+ try:
1647
+ provider = get_issue_provider(ws)
1648
+ except ProviderNotConfigured:
1649
+ return []
1650
+ try:
1651
+ issues = provider.list_my_issues(limit=limit)
1652
+ except ProviderNotConfigured:
1653
+ return []
1654
+ except IssueProviderError as e:
1655
+ return BlockerError(
1656
+ code="issue_provider_failed",
1657
+ what="Issue provider list call failed",
1658
+ details={"provider": ws.config.issue_provider.name, "error": str(e)},
1659
+ fix_actions=[
1660
+ FixAction(
1661
+ action="configure_provider",
1662
+ args={"provider": ws.config.issue_provider.name},
1663
+ safe=True,
1664
+ preview=f"verify {ws.config.issue_provider.name} config in canopy.toml + .canopy/mcps.json",
1665
+ ),
1666
+ ],
1667
+ ).to_dict()
1668
+ return [i.to_dict() for i in issues]
1669
+
1670
+
1671
+ # ── Linear (deprecated aliases — kept one release cycle for backwards compat) ──
1672
+
1673
+
1674
+ @mcp.tool()
1675
+ def linear_my_issues(limit: int = 25) -> list[dict] | dict:
1676
+ """Deprecated. Use ``issue_list_my_issues`` instead.
1677
+
1678
+ Provider-agnostic alias surviving from the pre-M5 era. Forwards to
1679
+ ``issue_list_my_issues``; same return shape. Will be removed in a
1680
+ future release.
1681
+
1682
+ Args:
1683
+ limit: Maximum issues to return (default 25).
1684
+ """
1685
+ return issue_list_my_issues(limit=limit)
1686
+
1687
+
1688
+ @mcp.tool()
1689
+ def feature_link_linear(feature: str, issue: str) -> dict:
1690
+ """Attach a Linear issue to an existing feature lane.
1691
+
1692
+ Fetches the issue via the configured Linear MCP and updates
1693
+ features.json with its identifier, title, and URL. Use this from
1694
+ the VSCode dashboard's "Pick from my Linear issues" action when a
1695
+ lane was created without an issue attached.
1696
+
1697
+ Args:
1698
+ feature: Feature lane name or alias.
1699
+ issue: Linear issue identifier (e.g. "ENG-412").
1700
+
1701
+ Returns:
1702
+ The updated feature lane dict.
1703
+ """
1704
+ ws = _get_workspace()
1705
+ coordinator = FeatureCoordinator(ws)
1706
+ lane = coordinator.link_linear_issue(feature, issue)
1707
+ return lane.to_dict()
1708
+
1709
+
1710
+ # ── Sync ─────────────────────────────────────────────────────────────────
1711
+
1712
+ @mcp.tool()
1713
+ def sync(strategy: str = "rebase") -> dict:
1714
+ """Pull default branch and rebase/merge feature branches across repos.
1715
+
1716
+ Args:
1717
+ strategy: "rebase" or "merge".
1718
+ """
1719
+ ws = _get_workspace()
1720
+ return multi.sync_all(ws, strategy=strategy)
1721
+
1722
+
1723
+ @mcp.tool()
1724
+ def slots(rich: bool = True) -> dict:
1725
+ """Slot occupancy + (default) per-slot enrichment for the dashboard / agent.
1726
+
1727
+ With ``rich=True`` (default for MCP — what the dashboard and the agent
1728
+ both want), returns the full payload: per-repo branch, dirty + counts,
1729
+ ahead/behind, default branch, last commit, PR + CI rollup, unresolved
1730
+ bot threads, linear link, and the computed ``feature_state`` — for
1731
+ every occupied slot AND canonical. Empty slots are explicit ``null``.
1732
+
1733
+ With ``rich=False``, returns the lightweight shape from slots.json
1734
+ only (slot id → feature + last_touched). Use for cheap polling when
1735
+ the caller doesn't need PR/CI/bot data.
1736
+
1737
+ Slot ids returned here are stable and can be passed as feature
1738
+ aliases to any tool that accepts one (added in T14).
1739
+ """
1740
+ from ..actions import slots as slots_mod
1741
+ workspace = _get_workspace()
1742
+ if not rich:
1743
+ state = slots_mod.read_state(workspace)
1744
+ return state.to_dict() if state else {"canonical": None, "slots": {}}
1745
+ from ..actions.slot_details import rich_slots
1746
+ return rich_slots(workspace)
1747
+
1748
+
1749
+ @mcp.tool()
1750
+ def migrate_slots() -> dict:
1751
+ """One-shot migration from pre-3.0 canopy layout to the 3.0 slot model.
1752
+
1753
+ Renames .canopy/worktrees/<feature>/ → .canopy/worktrees/worktree-N/,
1754
+ rewrites canopy.toml (max_worktrees → slots), and migrates
1755
+ .canopy/state/active_feature.json → .canopy/state/slots.json.
1756
+
1757
+ Refuses to run if slots.json already exists (idempotency guard).
1758
+ Returns: {moved: [...], slots: {slot_id: feature}, canonical, slot_count}.
1759
+ """
1760
+ import os
1761
+ from pathlib import Path
1762
+ from ..actions.migrate_slots import migrate, AlreadyMigratedError, NotLegacyError
1763
+
1764
+ # Find workspace root via CANOPY_ROOT or walk up from cwd.
1765
+ env_root = os.environ.get("CANOPY_ROOT")
1766
+ if env_root:
1767
+ root = Path(env_root).resolve()
1768
+ else:
1769
+ root = Path.cwd().resolve()
1770
+ while root != root.parent:
1771
+ if (root / "canopy.toml").exists():
1772
+ break
1773
+ root = root.parent
1774
+ else:
1775
+ return {"error": "not in a canopy workspace"}
1776
+
1777
+ try:
1778
+ return migrate(root)
1779
+ except AlreadyMigratedError as e:
1780
+ return {"error": "already_migrated", "detail": str(e)}
1781
+ except NotLegacyError as e:
1782
+ return {"error": "nothing_to_migrate", "detail": str(e)}
1783
+
1784
+
1785
+ # ── Entry point ──────────────────────────────────────────────────────────
1786
+
1787
+ def main():
1788
+ import sys
1789
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
1790
+ from .. import __version__
1791
+ print(f"canopy-mcp {__version__}")
1792
+ return
1793
+ mcp.run(transport="stdio")
1794
+
1795
+
1796
+ if __name__ == "__main__":
1797
+ main()