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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- 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()
|