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/actions/doctor.py
ADDED
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
"""Workspace + install integrity checker and repair primitive.
|
|
2
|
+
|
|
3
|
+
The recovery entry point. When something feels off — a canopy command
|
|
4
|
+
fails opaquely, state files look stale, the agent's setup didn't propagate
|
|
5
|
+
across machines — this module diagnoses and (optionally) repairs.
|
|
6
|
+
|
|
7
|
+
Two flavors of check, same shape:
|
|
8
|
+
|
|
9
|
+
* **State-integrity** (10 categories): the workspace's own bookkeeping —
|
|
10
|
+
``heads.json``, ``active_feature.json``, ``preflight.json``,
|
|
11
|
+
``features.json``, ``.canopy/worktrees/``, per-repo post-checkout hooks,
|
|
12
|
+
branch existence per feature.
|
|
13
|
+
* **Install-staleness** (6 categories): the canopy installation around
|
|
14
|
+
the workspace — CLI binary version, MCP server version, workspace
|
|
15
|
+
``.mcp.json`` entry, the bundled skill at ``~/.claude/skills/``, and
|
|
16
|
+
duplicate vsix install dirs.
|
|
17
|
+
|
|
18
|
+
Each check function is pure (read-only) and returns a list of ``Issue``
|
|
19
|
+
records. Each repair function takes one ``Issue`` and returns a
|
|
20
|
+
``RepairResult``. The orchestrator ``doctor()`` runs the checks (filtered
|
|
21
|
+
by category and/or feature scope) and optionally invokes repairs.
|
|
22
|
+
|
|
23
|
+
The CLI consumes the result via :mod:`canopy.cli.render`; the MCP tool
|
|
24
|
+
returns the ``to_dict()`` shape directly. Same structure across surfaces.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import signal
|
|
32
|
+
import subprocess
|
|
33
|
+
import time
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Literal
|
|
38
|
+
|
|
39
|
+
from .. import __version__
|
|
40
|
+
from ..git import hooks as canopy_hooks
|
|
41
|
+
from ..git import repo as git
|
|
42
|
+
from ..workspace.workspace import Workspace
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Severity = Literal["info", "warn", "error"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Result types ─────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Issue:
|
|
53
|
+
"""A single diagnosed problem.
|
|
54
|
+
|
|
55
|
+
Mirrors :class:`canopy.actions.errors.BlockerError`'s shape so consumers
|
|
56
|
+
that already render structured errors can reuse their machinery. Unlike
|
|
57
|
+
BlockerError, an ``Issue`` is non-raising — checks return lists of them.
|
|
58
|
+
"""
|
|
59
|
+
code: str
|
|
60
|
+
severity: Severity
|
|
61
|
+
what: str
|
|
62
|
+
expected: Any = None
|
|
63
|
+
actual: Any = None
|
|
64
|
+
repo: str | None = None
|
|
65
|
+
feature: str | None = None
|
|
66
|
+
fix_action: str | None = None # human-readable hint (one line)
|
|
67
|
+
auto_fixable: bool = False
|
|
68
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
out: dict[str, Any] = {
|
|
72
|
+
"code": self.code,
|
|
73
|
+
"severity": self.severity,
|
|
74
|
+
"what": self.what,
|
|
75
|
+
"auto_fixable": self.auto_fixable,
|
|
76
|
+
}
|
|
77
|
+
if self.expected is not None:
|
|
78
|
+
out["expected"] = self.expected
|
|
79
|
+
if self.actual is not None:
|
|
80
|
+
out["actual"] = self.actual
|
|
81
|
+
if self.repo is not None:
|
|
82
|
+
out["repo"] = self.repo
|
|
83
|
+
if self.feature is not None:
|
|
84
|
+
out["feature"] = self.feature
|
|
85
|
+
if self.fix_action is not None:
|
|
86
|
+
out["fix_action"] = self.fix_action
|
|
87
|
+
if self.details:
|
|
88
|
+
out["details"] = dict(self.details)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class RepairResult:
|
|
94
|
+
code: str
|
|
95
|
+
success: bool
|
|
96
|
+
action_taken: str
|
|
97
|
+
error: str | None = None
|
|
98
|
+
reload_required: bool = False
|
|
99
|
+
repo: str | None = None
|
|
100
|
+
feature: str | None = None
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
out: dict[str, Any] = {
|
|
104
|
+
"code": self.code,
|
|
105
|
+
"success": self.success,
|
|
106
|
+
"action_taken": self.action_taken,
|
|
107
|
+
}
|
|
108
|
+
if self.error is not None:
|
|
109
|
+
out["error"] = self.error
|
|
110
|
+
if self.reload_required:
|
|
111
|
+
out["reload_required"] = True
|
|
112
|
+
if self.repo is not None:
|
|
113
|
+
out["repo"] = self.repo
|
|
114
|
+
if self.feature is not None:
|
|
115
|
+
out["feature"] = self.feature
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Categories ───────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
# Every code maps to (category, check_fn, repair_fn-or-None). The orchestrator
|
|
122
|
+
# walks this table — adding a new check is one new entry plus the two
|
|
123
|
+
# functions, no other plumbing changes needed.
|
|
124
|
+
STATE_CATEGORIES = {
|
|
125
|
+
"heads",
|
|
126
|
+
"active_feature",
|
|
127
|
+
"worktrees",
|
|
128
|
+
"hooks",
|
|
129
|
+
"preflight",
|
|
130
|
+
"features",
|
|
131
|
+
"branches",
|
|
132
|
+
"slots",
|
|
133
|
+
}
|
|
134
|
+
INSTALL_CATEGORIES = {"cli", "mcp", "skill", "vsix"}
|
|
135
|
+
ALL_CATEGORIES = STATE_CATEGORIES | INSTALL_CATEGORIES
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── State-integrity checks ───────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def check_heads_stale(workspace: Workspace) -> list[Issue]:
|
|
141
|
+
"""heads.json branch+sha vs ``git rev-parse HEAD`` per repo."""
|
|
142
|
+
state = canopy_hooks.read_heads_state(workspace.config.root)
|
|
143
|
+
if not state:
|
|
144
|
+
return []
|
|
145
|
+
issues: list[Issue] = []
|
|
146
|
+
for rs in workspace.repos:
|
|
147
|
+
recorded = state.get(rs.config.name)
|
|
148
|
+
if not recorded:
|
|
149
|
+
continue
|
|
150
|
+
if not rs.abs_path.exists():
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
current_sha = git.head_sha(rs.abs_path)
|
|
154
|
+
current_branch = git.current_branch(rs.abs_path)
|
|
155
|
+
except git.GitError:
|
|
156
|
+
continue
|
|
157
|
+
recorded_sha = recorded.get("sha", "")
|
|
158
|
+
recorded_branch = recorded.get("branch", "")
|
|
159
|
+
if recorded_sha != current_sha or recorded_branch != current_branch:
|
|
160
|
+
issues.append(Issue(
|
|
161
|
+
code="heads_stale",
|
|
162
|
+
severity="warn",
|
|
163
|
+
what=f"heads.json out of sync for {rs.config.name}",
|
|
164
|
+
expected={"branch": current_branch, "sha": current_sha},
|
|
165
|
+
actual={"branch": recorded_branch, "sha": recorded_sha},
|
|
166
|
+
repo=rs.config.name,
|
|
167
|
+
fix_action="rewrite heads.json from live git",
|
|
168
|
+
auto_fixable=True,
|
|
169
|
+
))
|
|
170
|
+
return issues
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_active_feature_orphan(workspace: Workspace) -> list[Issue]:
|
|
174
|
+
"""active_feature.json points at a feature missing from features.json."""
|
|
175
|
+
af = _read_raw_active_feature(workspace.config.root)
|
|
176
|
+
if not af:
|
|
177
|
+
return []
|
|
178
|
+
feature = af.get("feature")
|
|
179
|
+
if not feature:
|
|
180
|
+
return []
|
|
181
|
+
features = _load_features_raw(workspace.config.root)
|
|
182
|
+
if feature in features:
|
|
183
|
+
return []
|
|
184
|
+
return [Issue(
|
|
185
|
+
code="active_feature_orphan",
|
|
186
|
+
severity="error",
|
|
187
|
+
what=f"active_feature.json points at unknown feature '{feature}'",
|
|
188
|
+
expected="feature recorded in features.json",
|
|
189
|
+
actual=f"'{feature}' not in features.json",
|
|
190
|
+
feature=feature,
|
|
191
|
+
fix_action="clear active_feature.json",
|
|
192
|
+
auto_fixable=True,
|
|
193
|
+
)]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_active_feature_path_missing(workspace: Workspace) -> list[Issue]:
|
|
197
|
+
"""active_feature.json lists per_repo_paths that don't exist on disk."""
|
|
198
|
+
af = _read_raw_active_feature(workspace.config.root)
|
|
199
|
+
if not af:
|
|
200
|
+
return []
|
|
201
|
+
feature = af.get("feature") or ""
|
|
202
|
+
paths = af.get("per_repo_paths") or {}
|
|
203
|
+
if not isinstance(paths, dict):
|
|
204
|
+
return []
|
|
205
|
+
issues: list[Issue] = []
|
|
206
|
+
for repo_name, p in paths.items():
|
|
207
|
+
if not isinstance(p, str):
|
|
208
|
+
continue
|
|
209
|
+
if not Path(p).exists():
|
|
210
|
+
issues.append(Issue(
|
|
211
|
+
code="active_feature_path_missing",
|
|
212
|
+
severity="error",
|
|
213
|
+
what=f"active_feature.json path missing for {repo_name}",
|
|
214
|
+
expected=p,
|
|
215
|
+
actual="(does not exist)",
|
|
216
|
+
repo=repo_name,
|
|
217
|
+
feature=feature,
|
|
218
|
+
fix_action="re-resolve paths from features.json + worktree info",
|
|
219
|
+
auto_fixable=True,
|
|
220
|
+
))
|
|
221
|
+
return issues
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def check_worktree_orphan(workspace: Workspace) -> list[Issue]:
|
|
225
|
+
"""Worktree directories under .canopy/worktrees/ not referenced by any feature."""
|
|
226
|
+
wt_root = workspace.config.root / ".canopy" / "worktrees"
|
|
227
|
+
if not wt_root.exists():
|
|
228
|
+
return []
|
|
229
|
+
features = _load_features_raw(workspace.config.root)
|
|
230
|
+
issues: list[Issue] = []
|
|
231
|
+
for feat_dir in sorted(wt_root.iterdir()):
|
|
232
|
+
if not feat_dir.is_dir():
|
|
233
|
+
continue
|
|
234
|
+
feature_name = feat_dir.name
|
|
235
|
+
feature_data = features.get(feature_name)
|
|
236
|
+
feature_repos = (feature_data or {}).get("repos") or []
|
|
237
|
+
for repo_dir in sorted(feat_dir.iterdir()):
|
|
238
|
+
if not repo_dir.is_dir():
|
|
239
|
+
continue
|
|
240
|
+
repo_name = repo_dir.name
|
|
241
|
+
if feature_data is None or repo_name not in feature_repos:
|
|
242
|
+
issues.append(Issue(
|
|
243
|
+
code="worktree_orphan",
|
|
244
|
+
severity="warn",
|
|
245
|
+
what=f"orphan worktree dir at {feat_dir.name}/{repo_name}",
|
|
246
|
+
expected="feature × repo referenced in features.json",
|
|
247
|
+
actual=str(repo_dir),
|
|
248
|
+
repo=repo_name,
|
|
249
|
+
feature=feature_name,
|
|
250
|
+
fix_action=f"git worktree remove --force {repo_dir}",
|
|
251
|
+
auto_fixable=True,
|
|
252
|
+
))
|
|
253
|
+
return issues
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def check_worktree_missing(workspace: Workspace) -> list[Issue]:
|
|
257
|
+
"""features.json lists worktree_paths for a feature×repo, but the dir is gone."""
|
|
258
|
+
features = _load_features_raw(workspace.config.root)
|
|
259
|
+
issues: list[Issue] = []
|
|
260
|
+
for name, data in features.items():
|
|
261
|
+
if not isinstance(data, dict):
|
|
262
|
+
continue
|
|
263
|
+
if data.get("status", "active") != "active":
|
|
264
|
+
continue
|
|
265
|
+
wt_paths = data.get("worktree_paths") or {}
|
|
266
|
+
if not isinstance(wt_paths, dict):
|
|
267
|
+
continue
|
|
268
|
+
for repo_name, p in wt_paths.items():
|
|
269
|
+
if not isinstance(p, str):
|
|
270
|
+
continue
|
|
271
|
+
if not Path(p).exists():
|
|
272
|
+
issues.append(Issue(
|
|
273
|
+
code="worktree_missing",
|
|
274
|
+
severity="error",
|
|
275
|
+
what=f"feature '{name}' worktree missing in {repo_name}",
|
|
276
|
+
expected=p,
|
|
277
|
+
actual="(does not exist)",
|
|
278
|
+
repo=repo_name,
|
|
279
|
+
feature=name,
|
|
280
|
+
fix_action="clear worktree_paths entry; mark cold for repo",
|
|
281
|
+
auto_fixable=True,
|
|
282
|
+
))
|
|
283
|
+
return issues
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_hook_missing(workspace: Workspace) -> list[Issue]:
|
|
287
|
+
"""Each managed repo should have canopy's post-checkout hook installed."""
|
|
288
|
+
issues: list[Issue] = []
|
|
289
|
+
for rs in workspace.repos:
|
|
290
|
+
if not rs.abs_path.exists():
|
|
291
|
+
continue
|
|
292
|
+
status = canopy_hooks.hook_status(rs.abs_path)
|
|
293
|
+
if status.get("installed"):
|
|
294
|
+
continue
|
|
295
|
+
if status.get("foreign_hook"):
|
|
296
|
+
issues.append(Issue(
|
|
297
|
+
code="hook_missing",
|
|
298
|
+
severity="error",
|
|
299
|
+
what=f"foreign post-checkout hook at {status['hook_path']}",
|
|
300
|
+
expected="canopy post-checkout hook (chained behind any user hook)",
|
|
301
|
+
actual="non-canopy hook present",
|
|
302
|
+
repo=rs.config.name,
|
|
303
|
+
fix_action="canopy hooks install (chains the existing hook)",
|
|
304
|
+
auto_fixable=True,
|
|
305
|
+
))
|
|
306
|
+
else:
|
|
307
|
+
issues.append(Issue(
|
|
308
|
+
code="hook_missing",
|
|
309
|
+
severity="error",
|
|
310
|
+
what=f"no post-checkout hook in {rs.config.name}",
|
|
311
|
+
expected="canopy post-checkout hook installed",
|
|
312
|
+
actual="(no hook)",
|
|
313
|
+
repo=rs.config.name,
|
|
314
|
+
fix_action="canopy hooks install",
|
|
315
|
+
auto_fixable=True,
|
|
316
|
+
))
|
|
317
|
+
return issues
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def check_hook_chained_unsafe(workspace: Workspace) -> list[Issue]:
|
|
321
|
+
"""Canopy installed but a chained hook is referenced and missing/broken."""
|
|
322
|
+
issues: list[Issue] = []
|
|
323
|
+
for rs in workspace.repos:
|
|
324
|
+
if not rs.abs_path.exists():
|
|
325
|
+
continue
|
|
326
|
+
hooks_dir = canopy_hooks.resolve_hooks_dir(rs.abs_path)
|
|
327
|
+
canopy_hook = hooks_dir / "post-checkout"
|
|
328
|
+
chained = hooks_dir / "post-checkout.canopy-chained"
|
|
329
|
+
if not canopy_hook.exists():
|
|
330
|
+
continue
|
|
331
|
+
text = canopy_hook.read_text()
|
|
332
|
+
# Our hook references the chained file by name; if the chained marker
|
|
333
|
+
# is referenced but the file is missing or non-executable, surface it.
|
|
334
|
+
if "post-checkout.canopy-chained" not in text:
|
|
335
|
+
continue
|
|
336
|
+
if not chained.exists():
|
|
337
|
+
# Reference is benign — the hook checks before exec'ing — but
|
|
338
|
+
# it might indicate the user expected a chained hook.
|
|
339
|
+
continue
|
|
340
|
+
if not os.access(chained, os.X_OK):
|
|
341
|
+
issues.append(Issue(
|
|
342
|
+
code="hook_chained_unsafe",
|
|
343
|
+
severity="warn",
|
|
344
|
+
what=f"chained hook is not executable in {rs.config.name}",
|
|
345
|
+
expected="executable post-checkout.canopy-chained",
|
|
346
|
+
actual=str(chained),
|
|
347
|
+
repo=rs.config.name,
|
|
348
|
+
fix_action="canopy hooks install --reinstall",
|
|
349
|
+
auto_fixable=True,
|
|
350
|
+
))
|
|
351
|
+
return issues
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def check_preflight_stale(workspace: Workspace) -> list[Issue]:
|
|
355
|
+
"""preflight.json recorded a result; HEAD has moved → result is no longer valid."""
|
|
356
|
+
path = workspace.config.root / ".canopy" / "state" / "preflight.json"
|
|
357
|
+
if not path.exists():
|
|
358
|
+
return []
|
|
359
|
+
try:
|
|
360
|
+
state = json.loads(path.read_text())
|
|
361
|
+
except (OSError, json.JSONDecodeError):
|
|
362
|
+
return []
|
|
363
|
+
if not isinstance(state, dict):
|
|
364
|
+
return []
|
|
365
|
+
issues: list[Issue] = []
|
|
366
|
+
for feature, entry in state.items():
|
|
367
|
+
if not isinstance(entry, dict):
|
|
368
|
+
continue
|
|
369
|
+
recorded = entry.get("head_sha_per_repo") or {}
|
|
370
|
+
if not isinstance(recorded, dict):
|
|
371
|
+
continue
|
|
372
|
+
for repo_name, sha in recorded.items():
|
|
373
|
+
try:
|
|
374
|
+
rs = workspace.get_repo(repo_name)
|
|
375
|
+
except KeyError:
|
|
376
|
+
# Unknown repo — features_unknown_repo will also flag this
|
|
377
|
+
continue
|
|
378
|
+
if not rs.abs_path.exists():
|
|
379
|
+
continue
|
|
380
|
+
try:
|
|
381
|
+
current = git.head_sha(rs.abs_path)
|
|
382
|
+
except git.GitError:
|
|
383
|
+
continue
|
|
384
|
+
if current and current != sha:
|
|
385
|
+
issues.append(Issue(
|
|
386
|
+
code="preflight_stale",
|
|
387
|
+
severity="info",
|
|
388
|
+
what=f"preflight result for '{feature}' is stale ({repo_name})",
|
|
389
|
+
expected={"sha": current},
|
|
390
|
+
actual={"sha": sha},
|
|
391
|
+
repo=repo_name,
|
|
392
|
+
feature=feature,
|
|
393
|
+
fix_action="clear stale preflight entry",
|
|
394
|
+
auto_fixable=True,
|
|
395
|
+
))
|
|
396
|
+
break # one issue per feature is enough
|
|
397
|
+
return issues
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def check_features_unknown_repo(workspace: Workspace) -> list[Issue]:
|
|
401
|
+
"""features.json references a repo not in canopy.toml."""
|
|
402
|
+
features = _load_features_raw(workspace.config.root)
|
|
403
|
+
known = {rc.name for rc in workspace.config.repos}
|
|
404
|
+
issues: list[Issue] = []
|
|
405
|
+
for name, data in features.items():
|
|
406
|
+
if not isinstance(data, dict):
|
|
407
|
+
continue
|
|
408
|
+
if data.get("status", "active") != "active":
|
|
409
|
+
continue
|
|
410
|
+
for repo_name in data.get("repos", []) or []:
|
|
411
|
+
if repo_name not in known:
|
|
412
|
+
issues.append(Issue(
|
|
413
|
+
code="features_unknown_repo",
|
|
414
|
+
severity="error",
|
|
415
|
+
what=f"feature '{name}' references unknown repo '{repo_name}'",
|
|
416
|
+
expected=f"repo '{repo_name}' in canopy.toml",
|
|
417
|
+
actual="(not configured)",
|
|
418
|
+
repo=repo_name,
|
|
419
|
+
feature=name,
|
|
420
|
+
fix_action="restore the repo or `canopy done` the feature",
|
|
421
|
+
auto_fixable=False,
|
|
422
|
+
))
|
|
423
|
+
return issues
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def check_branches_missing(workspace: Workspace) -> list[Issue]:
|
|
427
|
+
"""Feature has branches[repo] (or implicit branch=name) that doesn't exist locally."""
|
|
428
|
+
features = _load_features_raw(workspace.config.root)
|
|
429
|
+
issues: list[Issue] = []
|
|
430
|
+
for name, data in features.items():
|
|
431
|
+
if not isinstance(data, dict):
|
|
432
|
+
continue
|
|
433
|
+
if data.get("status", "active") != "active":
|
|
434
|
+
continue
|
|
435
|
+
repos = data.get("repos") or []
|
|
436
|
+
branches_map = data.get("branches") or {}
|
|
437
|
+
for repo_name in repos:
|
|
438
|
+
try:
|
|
439
|
+
rs = workspace.get_repo(repo_name)
|
|
440
|
+
except KeyError:
|
|
441
|
+
continue # features_unknown_repo handles this
|
|
442
|
+
if not rs.abs_path.exists():
|
|
443
|
+
continue
|
|
444
|
+
expected = branches_map.get(repo_name) or name
|
|
445
|
+
try:
|
|
446
|
+
exists = git.branch_exists(rs.abs_path, expected)
|
|
447
|
+
except git.GitError:
|
|
448
|
+
exists = False
|
|
449
|
+
if not exists:
|
|
450
|
+
issues.append(Issue(
|
|
451
|
+
code="branches_missing",
|
|
452
|
+
severity="error",
|
|
453
|
+
what=f"feature '{name}' branch '{expected}' missing in {repo_name}",
|
|
454
|
+
expected=expected,
|
|
455
|
+
actual="(no local branch)",
|
|
456
|
+
repo=repo_name,
|
|
457
|
+
feature=name,
|
|
458
|
+
fix_action="restore the branch or `canopy done` the feature",
|
|
459
|
+
auto_fixable=False,
|
|
460
|
+
))
|
|
461
|
+
return issues
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ── Slot-state checks ───────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def check_slot_dir_orphans(workspace: Workspace) -> list[Issue]:
|
|
468
|
+
"""Find .canopy/worktrees/worktree-N/ dirs with no entry in slots.json."""
|
|
469
|
+
import re
|
|
470
|
+
from . import slots as slots_mod
|
|
471
|
+
|
|
472
|
+
wt_base = workspace.config.root / ".canopy" / "worktrees"
|
|
473
|
+
if not wt_base.is_dir():
|
|
474
|
+
return []
|
|
475
|
+
state = slots_mod.read_state(workspace)
|
|
476
|
+
occupied = set(state.slots.keys()) if state is not None else set()
|
|
477
|
+
issues: list[Issue] = []
|
|
478
|
+
for d in sorted(wt_base.iterdir()):
|
|
479
|
+
if not d.is_dir():
|
|
480
|
+
continue
|
|
481
|
+
if not re.fullmatch(r"worktree-\d+", d.name):
|
|
482
|
+
continue
|
|
483
|
+
if d.name not in occupied:
|
|
484
|
+
issues.append(Issue(
|
|
485
|
+
code="slot_dir_orphan",
|
|
486
|
+
severity="warn",
|
|
487
|
+
what=f"slot dir '{d.name}' exists but no entry in slots.json",
|
|
488
|
+
expected="slot entry in slots.json",
|
|
489
|
+
actual=str(d),
|
|
490
|
+
fix_action=f"canopy doctor --gc removes {d.name}/; or canopy slot load <feature> {d.name}",
|
|
491
|
+
auto_fixable=False,
|
|
492
|
+
details={"slot": d.name, "path": str(d)},
|
|
493
|
+
))
|
|
494
|
+
return issues
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def check_slot_entry_orphans(workspace: Workspace) -> list[Issue]:
|
|
498
|
+
"""Find slots.json entries whose worktree dir is gone.
|
|
499
|
+
|
|
500
|
+
Reads raw JSON — ``read_state`` silently drops missing-dir entries,
|
|
501
|
+
which would hide them from this check.
|
|
502
|
+
"""
|
|
503
|
+
state_path = workspace.config.root / ".canopy" / "state" / "slots.json"
|
|
504
|
+
if not state_path.exists():
|
|
505
|
+
return []
|
|
506
|
+
try:
|
|
507
|
+
data = json.loads(state_path.read_text())
|
|
508
|
+
except (OSError, json.JSONDecodeError):
|
|
509
|
+
return []
|
|
510
|
+
wt_base = workspace.config.root / ".canopy" / "worktrees"
|
|
511
|
+
issues: list[Issue] = []
|
|
512
|
+
for sid, entry in (data.get("slots") or {}).items():
|
|
513
|
+
if not isinstance(entry, dict):
|
|
514
|
+
continue
|
|
515
|
+
if not (wt_base / sid).exists():
|
|
516
|
+
issues.append(Issue(
|
|
517
|
+
code="slot_entry_orphan",
|
|
518
|
+
severity="warn",
|
|
519
|
+
what=f"slots.json references '{sid}' but the dir is gone",
|
|
520
|
+
expected=str(wt_base / sid),
|
|
521
|
+
actual="(does not exist)",
|
|
522
|
+
feature=entry.get("feature"),
|
|
523
|
+
fix_action=f"drop the slots.json entry for {sid}",
|
|
524
|
+
auto_fixable=True,
|
|
525
|
+
details={"slot": sid, "feature": entry.get("feature"),
|
|
526
|
+
"expected_path": str(wt_base / sid)},
|
|
527
|
+
))
|
|
528
|
+
return issues
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def check_slot_branch_mismatches(workspace: Workspace) -> list[Issue]:
|
|
532
|
+
"""Find slots where the worktree HEAD doesn't match the feature's expected branch.
|
|
533
|
+
|
|
534
|
+
Detached HEAD is reported as a separate ``slot_detached_head`` finding
|
|
535
|
+
(info severity) — it's a recoverable user-driven state, not a real
|
|
536
|
+
branch mismatch.
|
|
537
|
+
"""
|
|
538
|
+
from . import slots as slots_mod
|
|
539
|
+
from .aliases import repos_for_feature
|
|
540
|
+
|
|
541
|
+
state = slots_mod.read_state(workspace)
|
|
542
|
+
if state is None:
|
|
543
|
+
return []
|
|
544
|
+
issues: list[Issue] = []
|
|
545
|
+
for sid, entry in state.slots.items():
|
|
546
|
+
repo_branches = repos_for_feature(workspace, entry.feature) or {}
|
|
547
|
+
for repo_name, expected_branch in repo_branches.items():
|
|
548
|
+
slot_path = slots_mod.slot_worktree_path(workspace, sid, repo_name)
|
|
549
|
+
if not slot_path.exists():
|
|
550
|
+
continue
|
|
551
|
+
try:
|
|
552
|
+
actual_branch = git.current_branch(slot_path)
|
|
553
|
+
except Exception:
|
|
554
|
+
continue
|
|
555
|
+
if actual_branch == expected_branch:
|
|
556
|
+
continue
|
|
557
|
+
if actual_branch == "(detached)":
|
|
558
|
+
# Detached HEAD is a separate, lighter finding — the user
|
|
559
|
+
# explicitly detached (e.g., `git checkout <sha>`) and the
|
|
560
|
+
# slot can be re-attached with a single `git checkout`.
|
|
561
|
+
issues.append(Issue(
|
|
562
|
+
code="slot_detached_head",
|
|
563
|
+
severity="info",
|
|
564
|
+
what=(
|
|
565
|
+
f"slot '{sid}' repo '{repo_name}' has detached HEAD"
|
|
566
|
+
f" (feature '{entry.feature}' expects '{expected_branch}')"
|
|
567
|
+
),
|
|
568
|
+
expected=expected_branch,
|
|
569
|
+
actual="(detached)",
|
|
570
|
+
repo=repo_name,
|
|
571
|
+
feature=entry.feature,
|
|
572
|
+
fix_action=(
|
|
573
|
+
f"git checkout {expected_branch} in {sid}/{repo_name}"
|
|
574
|
+
f" to re-attach"
|
|
575
|
+
),
|
|
576
|
+
auto_fixable=False,
|
|
577
|
+
details={
|
|
578
|
+
"slot": sid, "feature": entry.feature, "repo": repo_name,
|
|
579
|
+
"expected_branch": expected_branch,
|
|
580
|
+
},
|
|
581
|
+
))
|
|
582
|
+
continue
|
|
583
|
+
issues.append(Issue(
|
|
584
|
+
code="slot_branch_mismatch",
|
|
585
|
+
severity="warn",
|
|
586
|
+
what=(
|
|
587
|
+
f"slot '{sid}' repo '{repo_name}' is on '{actual_branch}'"
|
|
588
|
+
f" but feature '{entry.feature}' expects '{expected_branch}'"
|
|
589
|
+
),
|
|
590
|
+
expected=expected_branch,
|
|
591
|
+
actual=actual_branch,
|
|
592
|
+
repo=repo_name,
|
|
593
|
+
feature=entry.feature,
|
|
594
|
+
fix_action=(
|
|
595
|
+
f"git checkout {expected_branch} in {sid}/{repo_name};"
|
|
596
|
+
f" or re-record via canopy slot load --replace"
|
|
597
|
+
),
|
|
598
|
+
auto_fixable=False,
|
|
599
|
+
details={
|
|
600
|
+
"slot": sid, "feature": entry.feature, "repo": repo_name,
|
|
601
|
+
"expected_branch": expected_branch, "actual_branch": actual_branch,
|
|
602
|
+
},
|
|
603
|
+
))
|
|
604
|
+
return issues
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ── Install-staleness checks ─────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def check_cli_stale(workspace: Workspace) -> list[Issue]:
|
|
611
|
+
"""`canopy --version` (PATH) is older than the running ``__version__``."""
|
|
612
|
+
cli = shutil.which("canopy")
|
|
613
|
+
if not cli:
|
|
614
|
+
return [Issue(
|
|
615
|
+
code="cli_stale",
|
|
616
|
+
severity="warn",
|
|
617
|
+
what="`canopy` not found on PATH",
|
|
618
|
+
expected=f"canopy {__version__} on PATH",
|
|
619
|
+
actual="(not found)",
|
|
620
|
+
fix_action="reinstall canopy (pipx install canopy or pip install canopy)",
|
|
621
|
+
auto_fixable=False,
|
|
622
|
+
)]
|
|
623
|
+
installed = _read_binary_version(cli)
|
|
624
|
+
if installed is None:
|
|
625
|
+
return [] # can't determine; don't flag
|
|
626
|
+
if _is_older(installed, __version__):
|
|
627
|
+
return [Issue(
|
|
628
|
+
code="cli_stale",
|
|
629
|
+
severity="warn",
|
|
630
|
+
what=f"installed canopy CLI ({installed}) is older than {__version__}",
|
|
631
|
+
expected=__version__,
|
|
632
|
+
actual=installed,
|
|
633
|
+
fix_action="reinstall canopy (pipx upgrade canopy or pip install -U canopy)",
|
|
634
|
+
auto_fixable=False,
|
|
635
|
+
details={"path": cli},
|
|
636
|
+
)]
|
|
637
|
+
return []
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def check_mcp_stale(workspace: Workspace) -> list[Issue]:
|
|
641
|
+
"""`canopy-mcp --version` is older than the running ``__version__``."""
|
|
642
|
+
mcp_bin = shutil.which("canopy-mcp")
|
|
643
|
+
if not mcp_bin:
|
|
644
|
+
return [Issue(
|
|
645
|
+
code="mcp_stale",
|
|
646
|
+
severity="error",
|
|
647
|
+
what="`canopy-mcp` not found on PATH",
|
|
648
|
+
expected=f"canopy-mcp {__version__} on PATH",
|
|
649
|
+
actual="(not found)",
|
|
650
|
+
fix_action="reinstall canopy (provides the canopy-mcp entry point)",
|
|
651
|
+
auto_fixable=False,
|
|
652
|
+
)]
|
|
653
|
+
installed = _read_binary_version(mcp_bin)
|
|
654
|
+
if installed is None:
|
|
655
|
+
return []
|
|
656
|
+
if _is_older(installed, __version__):
|
|
657
|
+
return [Issue(
|
|
658
|
+
code="mcp_stale",
|
|
659
|
+
severity="error",
|
|
660
|
+
what=f"installed canopy-mcp ({installed}) is older than {__version__}",
|
|
661
|
+
expected=__version__,
|
|
662
|
+
actual=installed,
|
|
663
|
+
fix_action="reinstall canopy (pipx upgrade canopy or pip install -U canopy)",
|
|
664
|
+
auto_fixable=False,
|
|
665
|
+
details={"path": mcp_bin},
|
|
666
|
+
)]
|
|
667
|
+
return []
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def check_mcp_missing_in_workspace(workspace: Workspace) -> list[Issue]:
|
|
671
|
+
"""workspace .mcp.json lacks a canopy entry, or its CANOPY_ROOT is wrong."""
|
|
672
|
+
from ..agent_setup import mcp_config_path
|
|
673
|
+
|
|
674
|
+
target = mcp_config_path(workspace.config.root)
|
|
675
|
+
expected_root = str(workspace.config.root.resolve())
|
|
676
|
+
if not target.exists():
|
|
677
|
+
return [Issue(
|
|
678
|
+
code="mcp_missing_in_workspace",
|
|
679
|
+
severity="error",
|
|
680
|
+
what=".mcp.json missing in workspace",
|
|
681
|
+
expected=f"canopy entry with CANOPY_ROOT={expected_root}",
|
|
682
|
+
actual="(file not present)",
|
|
683
|
+
fix_action="canopy setup-agent (writes .mcp.json)",
|
|
684
|
+
auto_fixable=True,
|
|
685
|
+
details={"path": str(target)},
|
|
686
|
+
)]
|
|
687
|
+
try:
|
|
688
|
+
cfg = json.loads(target.read_text())
|
|
689
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
690
|
+
return [Issue(
|
|
691
|
+
code="mcp_missing_in_workspace",
|
|
692
|
+
severity="error",
|
|
693
|
+
what=f".mcp.json is invalid: {e}",
|
|
694
|
+
expected="valid JSON with mcpServers.canopy entry",
|
|
695
|
+
actual="(parse error)",
|
|
696
|
+
fix_action="fix or remove .mcp.json, then `canopy setup-agent`",
|
|
697
|
+
auto_fixable=False,
|
|
698
|
+
details={"path": str(target)},
|
|
699
|
+
)]
|
|
700
|
+
servers = (cfg.get("mcpServers") if isinstance(cfg, dict) else {}) or {}
|
|
701
|
+
entry = servers.get("canopy") if isinstance(servers, dict) else None
|
|
702
|
+
if not isinstance(entry, dict) or entry.get("command") != "canopy-mcp":
|
|
703
|
+
return [Issue(
|
|
704
|
+
code="mcp_missing_in_workspace",
|
|
705
|
+
severity="error",
|
|
706
|
+
what="no canopy entry in .mcp.json",
|
|
707
|
+
expected=f"canopy entry with CANOPY_ROOT={expected_root}",
|
|
708
|
+
actual="(missing or wrong command)",
|
|
709
|
+
fix_action="canopy setup-agent (adds canopy entry)",
|
|
710
|
+
auto_fixable=True,
|
|
711
|
+
details={"path": str(target)},
|
|
712
|
+
)]
|
|
713
|
+
actual_root = (entry.get("env") or {}).get("CANOPY_ROOT", "")
|
|
714
|
+
if actual_root != expected_root:
|
|
715
|
+
return [Issue(
|
|
716
|
+
code="mcp_missing_in_workspace",
|
|
717
|
+
severity="error",
|
|
718
|
+
what="canopy entry CANOPY_ROOT does not match workspace root",
|
|
719
|
+
expected=expected_root,
|
|
720
|
+
actual=actual_root,
|
|
721
|
+
fix_action="canopy setup-agent --reinstall (rewrites entry)",
|
|
722
|
+
auto_fixable=True,
|
|
723
|
+
details={"path": str(target)},
|
|
724
|
+
)]
|
|
725
|
+
return []
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def check_skill_missing(workspace: Workspace) -> list[Issue]:
|
|
729
|
+
"""No SKILL.md at ~/.claude/skills/using-canopy/."""
|
|
730
|
+
from ..agent_setup import skill_install_target
|
|
731
|
+
|
|
732
|
+
target = skill_install_target()
|
|
733
|
+
if target.exists():
|
|
734
|
+
return []
|
|
735
|
+
return [Issue(
|
|
736
|
+
code="skill_missing",
|
|
737
|
+
severity="warn",
|
|
738
|
+
what="using-canopy skill not installed",
|
|
739
|
+
expected=str(target),
|
|
740
|
+
actual="(not present)",
|
|
741
|
+
fix_action="canopy setup-agent",
|
|
742
|
+
auto_fixable=True,
|
|
743
|
+
)]
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def check_skill_stale(workspace: Workspace) -> list[Issue]:
|
|
747
|
+
"""Installed SKILL.md doesn't byte-match the bundled source."""
|
|
748
|
+
from ..agent_setup import _SKILL_SOURCE, skill_install_target
|
|
749
|
+
|
|
750
|
+
target = skill_install_target()
|
|
751
|
+
if not target.exists():
|
|
752
|
+
return [] # missing, not stale — skill_missing handles it
|
|
753
|
+
try:
|
|
754
|
+
installed = target.read_text()
|
|
755
|
+
bundled = _SKILL_SOURCE.read_text()
|
|
756
|
+
except OSError:
|
|
757
|
+
return []
|
|
758
|
+
if installed == bundled:
|
|
759
|
+
return []
|
|
760
|
+
is_canopy = "name: using-canopy" in installed
|
|
761
|
+
if not is_canopy:
|
|
762
|
+
# foreign skill at our path — install_skill won't overwrite without
|
|
763
|
+
# --reinstall, so flag for user attention.
|
|
764
|
+
return [Issue(
|
|
765
|
+
code="skill_stale",
|
|
766
|
+
severity="warn",
|
|
767
|
+
what="foreign skill at using-canopy path",
|
|
768
|
+
expected="canopy's bundled skill",
|
|
769
|
+
actual="(non-canopy content)",
|
|
770
|
+
fix_action="canopy setup-agent --reinstall (overwrites)",
|
|
771
|
+
auto_fixable=False,
|
|
772
|
+
details={"path": str(target)},
|
|
773
|
+
)]
|
|
774
|
+
return [Issue(
|
|
775
|
+
code="skill_stale",
|
|
776
|
+
severity="warn",
|
|
777
|
+
what="using-canopy skill content drifted from bundled source",
|
|
778
|
+
expected="byte-equal with bundled skill",
|
|
779
|
+
actual="(diff)",
|
|
780
|
+
fix_action="canopy setup-agent --reinstall",
|
|
781
|
+
auto_fixable=True,
|
|
782
|
+
details={"path": str(target)},
|
|
783
|
+
)]
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
_VSIX_PREFIX = "singularityinc.canopy-"
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def check_mcp_orphans(workspace: Workspace) -> list[Issue]:
|
|
790
|
+
"""Detect orphaned ``canopy-mcp`` processes (parent died, reparented to PID 1).
|
|
791
|
+
|
|
792
|
+
Stale MCP servers accumulate when an editor / agent disconnects without
|
|
793
|
+
cleanly closing stdin — the server keeps running waiting for input
|
|
794
|
+
that never comes. Each orphan is idle but holds a venv-Python process
|
|
795
|
+
+ a few MB of RSS. ``--fix`` reaps them with SIGTERM (then SIGKILL
|
|
796
|
+
after a short grace) so the process table stays clean.
|
|
797
|
+
|
|
798
|
+
See test-findings F-3 (~8 stale processes accumulated over a week of
|
|
799
|
+
real use of canopy-test before this was added).
|
|
800
|
+
"""
|
|
801
|
+
pids = _list_orphan_canopy_mcp_pids()
|
|
802
|
+
if not pids:
|
|
803
|
+
return []
|
|
804
|
+
return [Issue(
|
|
805
|
+
code="mcp_orphans",
|
|
806
|
+
severity="info",
|
|
807
|
+
what=f"{len(pids)} orphaned canopy-mcp process(es) found (PPID=1)",
|
|
808
|
+
expected="0 orphans (each MCP server should exit when its parent disconnects)",
|
|
809
|
+
actual=str(len(pids)),
|
|
810
|
+
fix_action="canopy doctor --fix reaps them (SIGTERM, then SIGKILL after 2s)",
|
|
811
|
+
auto_fixable=True,
|
|
812
|
+
details={"pids": pids},
|
|
813
|
+
)]
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _list_orphan_canopy_mcp_pids() -> list[int]:
|
|
817
|
+
"""Return PIDs of running ``canopy-mcp`` processes whose parent is PID 1.
|
|
818
|
+
|
|
819
|
+
Uses ``ps`` (cross-platform on macOS + Linux) — no extra dependency.
|
|
820
|
+
Skips the current process and its ancestors so a doctor invocation
|
|
821
|
+
from inside an MCP context can't report itself.
|
|
822
|
+
"""
|
|
823
|
+
try:
|
|
824
|
+
out = subprocess.run(
|
|
825
|
+
["ps", "-eo", "pid=,ppid=,command="],
|
|
826
|
+
capture_output=True, text=True, timeout=5,
|
|
827
|
+
)
|
|
828
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
829
|
+
return []
|
|
830
|
+
if out.returncode != 0:
|
|
831
|
+
return []
|
|
832
|
+
self_pid = os.getpid()
|
|
833
|
+
self_ppid = os.getppid()
|
|
834
|
+
skip = {self_pid, self_ppid}
|
|
835
|
+
out_pids: list[int] = []
|
|
836
|
+
for line in out.stdout.splitlines():
|
|
837
|
+
try:
|
|
838
|
+
pid_s, ppid_s, command = line.lstrip().split(None, 2)
|
|
839
|
+
pid, ppid = int(pid_s), int(ppid_s)
|
|
840
|
+
except (ValueError, IndexError):
|
|
841
|
+
continue
|
|
842
|
+
if pid in skip or ppid in skip:
|
|
843
|
+
continue
|
|
844
|
+
if "canopy-mcp" not in command:
|
|
845
|
+
continue
|
|
846
|
+
if ppid == 1:
|
|
847
|
+
out_pids.append(pid)
|
|
848
|
+
return sorted(out_pids)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def check_vsix_duplicates(workspace: Workspace) -> list[Issue]:
|
|
852
|
+
"""Multiple ``singularityinc.canopy-*`` dirs in ~/.vscode/extensions/."""
|
|
853
|
+
ext_dir = Path.home() / ".vscode" / "extensions"
|
|
854
|
+
if not ext_dir.exists():
|
|
855
|
+
return []
|
|
856
|
+
candidates = sorted(
|
|
857
|
+
d for d in ext_dir.iterdir()
|
|
858
|
+
if d.is_dir() and d.name.startswith(_VSIX_PREFIX)
|
|
859
|
+
)
|
|
860
|
+
if len(candidates) <= 1:
|
|
861
|
+
return []
|
|
862
|
+
return [Issue(
|
|
863
|
+
code="vsix_duplicates",
|
|
864
|
+
severity="info",
|
|
865
|
+
what=f"{len(candidates)} canopy vsix install dirs found",
|
|
866
|
+
expected="1 install dir",
|
|
867
|
+
actual=str(len(candidates)),
|
|
868
|
+
fix_action="canopy doctor --clean-vsix (keeps newest)",
|
|
869
|
+
auto_fixable=True,
|
|
870
|
+
details={"paths": [str(p) for p in candidates]},
|
|
871
|
+
)]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ── Check registry ───────────────────────────────────────────────────────
|
|
875
|
+
|
|
876
|
+
# code → (category, check_fn). Registry-driven so `--fix=<category>` and
|
|
877
|
+
# feature-scoped runs are simple filters.
|
|
878
|
+
_CHECKS: dict[str, tuple[str, Any]] = {
|
|
879
|
+
"heads_stale": ("heads", check_heads_stale),
|
|
880
|
+
"active_feature_orphan": ("active_feature", check_active_feature_orphan),
|
|
881
|
+
"active_feature_path_missing": ("active_feature", check_active_feature_path_missing),
|
|
882
|
+
"worktree_orphan": ("worktrees", check_worktree_orphan),
|
|
883
|
+
"worktree_missing": ("worktrees", check_worktree_missing),
|
|
884
|
+
"hook_missing": ("hooks", check_hook_missing),
|
|
885
|
+
"hook_chained_unsafe": ("hooks", check_hook_chained_unsafe),
|
|
886
|
+
"preflight_stale": ("preflight", check_preflight_stale),
|
|
887
|
+
"features_unknown_repo": ("features", check_features_unknown_repo),
|
|
888
|
+
"branches_missing": ("branches", check_branches_missing),
|
|
889
|
+
"cli_stale": ("cli", check_cli_stale),
|
|
890
|
+
"mcp_stale": ("mcp", check_mcp_stale),
|
|
891
|
+
"mcp_missing_in_workspace": ("mcp", check_mcp_missing_in_workspace),
|
|
892
|
+
"skill_missing": ("skill", check_skill_missing),
|
|
893
|
+
"skill_stale": ("skill", check_skill_stale),
|
|
894
|
+
"mcp_orphans": ("mcp", check_mcp_orphans),
|
|
895
|
+
"vsix_duplicates": ("vsix", check_vsix_duplicates),
|
|
896
|
+
"slot_dir_orphan": ("slots", check_slot_dir_orphans),
|
|
897
|
+
"slot_entry_orphan": ("slots", check_slot_entry_orphans),
|
|
898
|
+
"slot_branch_mismatch": ("slots", check_slot_branch_mismatches),
|
|
899
|
+
# slot_detached_head shares its check function with slot_branch_mismatch
|
|
900
|
+
# (one walker emits both codes). The registry entry uses a sentinel
|
|
901
|
+
# check that returns [] so the orchestrator doesn't double-emit; the
|
|
902
|
+
# fix-loop lookup still finds the category for category filtering.
|
|
903
|
+
"slot_detached_head": ("slots", lambda _ws: []),
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# ── Repairs ──────────────────────────────────────────────────────────────
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def repair_heads_stale(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
911
|
+
"""Rewrite heads.json from live git for the affected repo."""
|
|
912
|
+
repo_name = issue.repo
|
|
913
|
+
if not repo_name:
|
|
914
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
915
|
+
error="missing repo on issue")
|
|
916
|
+
try:
|
|
917
|
+
rs = workspace.get_repo(repo_name)
|
|
918
|
+
except KeyError as e:
|
|
919
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
920
|
+
error=str(e), repo=repo_name)
|
|
921
|
+
state_path = workspace.config.root / ".canopy" / "state" / "heads.json"
|
|
922
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
923
|
+
try:
|
|
924
|
+
state = canopy_hooks.read_heads_state(workspace.config.root)
|
|
925
|
+
except Exception:
|
|
926
|
+
state = {}
|
|
927
|
+
try:
|
|
928
|
+
sha = git.head_sha(rs.abs_path)
|
|
929
|
+
branch = git.current_branch(rs.abs_path)
|
|
930
|
+
except git.GitError as e:
|
|
931
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
932
|
+
error=str(e), repo=repo_name)
|
|
933
|
+
state[repo_name] = {
|
|
934
|
+
"branch": branch, "sha": sha, "prev_sha": sha,
|
|
935
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
936
|
+
}
|
|
937
|
+
tmp = state_path.with_suffix(".json.tmp")
|
|
938
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
939
|
+
tmp.replace(state_path)
|
|
940
|
+
return RepairResult(
|
|
941
|
+
code=issue.code, success=True, repo=repo_name,
|
|
942
|
+
action_taken=f"rewrote heads.json[{repo_name}] from live HEAD",
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def repair_active_feature_orphan(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
947
|
+
"""Clear active_feature.json (feature it points at no longer exists)."""
|
|
948
|
+
path = workspace.config.root / ".canopy" / "state" / "active_feature.json"
|
|
949
|
+
if path.exists():
|
|
950
|
+
path.unlink()
|
|
951
|
+
return RepairResult(
|
|
952
|
+
code=issue.code, success=True, feature=issue.feature,
|
|
953
|
+
action_taken="removed active_feature.json",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def repair_active_feature_path_missing(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
958
|
+
"""Re-resolve per_repo_paths from features.json + worktree info, or clear if unrecoverable."""
|
|
959
|
+
path = workspace.config.root / ".canopy" / "state" / "active_feature.json"
|
|
960
|
+
if not path.exists():
|
|
961
|
+
return RepairResult(code=issue.code, success=True,
|
|
962
|
+
action_taken="active_feature.json already absent")
|
|
963
|
+
try:
|
|
964
|
+
data = json.loads(path.read_text())
|
|
965
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
966
|
+
path.unlink()
|
|
967
|
+
return RepairResult(code=issue.code, success=True,
|
|
968
|
+
action_taken="removed unparseable active_feature.json",
|
|
969
|
+
error=str(e))
|
|
970
|
+
feature = data.get("feature")
|
|
971
|
+
features = _load_features_raw(workspace.config.root)
|
|
972
|
+
feature_data = features.get(feature) if isinstance(feature, str) else None
|
|
973
|
+
if not feature_data or not isinstance(feature_data, dict):
|
|
974
|
+
path.unlink()
|
|
975
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
976
|
+
action_taken="removed active_feature.json (no recoverable feature)")
|
|
977
|
+
new_paths: dict[str, str] = {}
|
|
978
|
+
wt_paths = feature_data.get("worktree_paths") or {}
|
|
979
|
+
for repo_name in feature_data.get("repos", []):
|
|
980
|
+
if isinstance(wt_paths, dict) and isinstance(wt_paths.get(repo_name), str):
|
|
981
|
+
p = wt_paths[repo_name]
|
|
982
|
+
if Path(p).exists():
|
|
983
|
+
new_paths[repo_name] = p
|
|
984
|
+
continue
|
|
985
|
+
# Fallback: main repo path from canopy.toml
|
|
986
|
+
try:
|
|
987
|
+
rs = workspace.get_repo(repo_name)
|
|
988
|
+
except KeyError:
|
|
989
|
+
continue
|
|
990
|
+
if rs.abs_path.exists():
|
|
991
|
+
new_paths[repo_name] = str(rs.abs_path)
|
|
992
|
+
data["per_repo_paths"] = new_paths
|
|
993
|
+
tmp = path.with_suffix(".json.tmp")
|
|
994
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
995
|
+
tmp.replace(path)
|
|
996
|
+
return RepairResult(
|
|
997
|
+
code=issue.code, success=True, feature=feature, repo=issue.repo,
|
|
998
|
+
action_taken=f"re-resolved per_repo_paths ({len(new_paths)} repos)",
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def repair_worktree_orphan(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1003
|
+
"""Remove the orphan worktree directory via ``git worktree remove --force``.
|
|
1004
|
+
|
|
1005
|
+
Falls back to ``rmtree`` if git refuses (e.g., the directory isn't a
|
|
1006
|
+
registered worktree any more).
|
|
1007
|
+
"""
|
|
1008
|
+
repo_name = issue.repo
|
|
1009
|
+
feature = issue.feature
|
|
1010
|
+
if not repo_name or not feature:
|
|
1011
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1012
|
+
error="missing repo/feature on issue")
|
|
1013
|
+
target = workspace.config.root / ".canopy" / "worktrees" / feature / repo_name
|
|
1014
|
+
if not target.exists():
|
|
1015
|
+
return RepairResult(code=issue.code, success=True,
|
|
1016
|
+
action_taken="orphan dir already gone",
|
|
1017
|
+
repo=repo_name, feature=feature)
|
|
1018
|
+
# Try canonical git worktree remove against the parent repo. The repo
|
|
1019
|
+
# might itself be a worktree; resolve to the main path before issuing.
|
|
1020
|
+
try:
|
|
1021
|
+
rs = workspace.get_repo(repo_name)
|
|
1022
|
+
repo_root = git.worktree_main_path(rs.abs_path) or rs.abs_path
|
|
1023
|
+
git.worktree_remove(repo_root, target, force=True)
|
|
1024
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1025
|
+
feature=feature,
|
|
1026
|
+
action_taken=f"git worktree remove --force {target}")
|
|
1027
|
+
except (KeyError, git.GitError):
|
|
1028
|
+
# fall through to rmtree
|
|
1029
|
+
pass
|
|
1030
|
+
try:
|
|
1031
|
+
shutil.rmtree(target)
|
|
1032
|
+
except OSError as e:
|
|
1033
|
+
return RepairResult(code=issue.code, success=False, repo=repo_name,
|
|
1034
|
+
feature=feature, action_taken="",
|
|
1035
|
+
error=f"rmtree failed: {e}")
|
|
1036
|
+
# Cleanup empty parent feature dir
|
|
1037
|
+
parent = target.parent
|
|
1038
|
+
try:
|
|
1039
|
+
if parent.exists() and not any(parent.iterdir()):
|
|
1040
|
+
parent.rmdir()
|
|
1041
|
+
except OSError:
|
|
1042
|
+
pass
|
|
1043
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1044
|
+
feature=feature,
|
|
1045
|
+
action_taken=f"rmtree {target} (git worktree remove unavailable)")
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def repair_worktree_missing(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1049
|
+
"""Drop the worktree_paths entry for this repo from features.json."""
|
|
1050
|
+
feature = issue.feature
|
|
1051
|
+
repo_name = issue.repo
|
|
1052
|
+
if not feature or not repo_name:
|
|
1053
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1054
|
+
error="missing repo/feature on issue")
|
|
1055
|
+
features = _load_features_raw(workspace.config.root)
|
|
1056
|
+
data = features.get(feature)
|
|
1057
|
+
if not isinstance(data, dict):
|
|
1058
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1059
|
+
action_taken="feature no longer in features.json")
|
|
1060
|
+
wt_paths = data.get("worktree_paths")
|
|
1061
|
+
if isinstance(wt_paths, dict) and repo_name in wt_paths:
|
|
1062
|
+
wt_paths.pop(repo_name)
|
|
1063
|
+
if not wt_paths:
|
|
1064
|
+
data.pop("worktree_paths", None)
|
|
1065
|
+
data.pop("use_worktrees", None)
|
|
1066
|
+
_save_features_raw(workspace.config.root, features)
|
|
1067
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1068
|
+
repo=repo_name,
|
|
1069
|
+
action_taken=f"cleared worktree_paths[{repo_name}] in features.json")
|
|
1070
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1071
|
+
repo=repo_name,
|
|
1072
|
+
action_taken="no worktree_paths entry to clear")
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def repair_hook_missing(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1076
|
+
"""Reinstall the post-checkout hook for the affected repo."""
|
|
1077
|
+
repo_name = issue.repo
|
|
1078
|
+
if not repo_name:
|
|
1079
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1080
|
+
error="missing repo on issue")
|
|
1081
|
+
try:
|
|
1082
|
+
rs = workspace.get_repo(repo_name)
|
|
1083
|
+
except KeyError as e:
|
|
1084
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1085
|
+
error=str(e), repo=repo_name)
|
|
1086
|
+
if not rs.abs_path.exists():
|
|
1087
|
+
return RepairResult(code=issue.code, success=False, repo=repo_name,
|
|
1088
|
+
action_taken="",
|
|
1089
|
+
error=f"repo path does not exist: {rs.abs_path}")
|
|
1090
|
+
result = canopy_hooks.install_hook(
|
|
1091
|
+
rs.abs_path, repo_name, workspace.config.root,
|
|
1092
|
+
)
|
|
1093
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1094
|
+
action_taken=f"hook {result.action} at {result.path}")
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def repair_hook_chained_unsafe(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1098
|
+
"""Make the chained hook executable (or reinstall via ``install_hook``)."""
|
|
1099
|
+
repo_name = issue.repo
|
|
1100
|
+
if not repo_name:
|
|
1101
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1102
|
+
error="missing repo on issue")
|
|
1103
|
+
try:
|
|
1104
|
+
rs = workspace.get_repo(repo_name)
|
|
1105
|
+
except KeyError as e:
|
|
1106
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1107
|
+
error=str(e), repo=repo_name)
|
|
1108
|
+
hooks_dir = canopy_hooks.resolve_hooks_dir(rs.abs_path)
|
|
1109
|
+
chained = hooks_dir / "post-checkout.canopy-chained"
|
|
1110
|
+
if chained.exists() and not os.access(chained, os.X_OK):
|
|
1111
|
+
mode = chained.stat().st_mode
|
|
1112
|
+
chained.chmod(mode | 0o111)
|
|
1113
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1114
|
+
action_taken=f"chmod +x {chained}")
|
|
1115
|
+
return RepairResult(code=issue.code, success=True, repo=repo_name,
|
|
1116
|
+
action_taken="nothing to do")
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def repair_preflight_stale(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1120
|
+
"""Drop stale preflight entries (whose recorded sha doesn't match HEAD)."""
|
|
1121
|
+
feature = issue.feature
|
|
1122
|
+
path = workspace.config.root / ".canopy" / "state" / "preflight.json"
|
|
1123
|
+
if not path.exists():
|
|
1124
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1125
|
+
action_taken="preflight.json absent")
|
|
1126
|
+
try:
|
|
1127
|
+
state = json.loads(path.read_text())
|
|
1128
|
+
except (OSError, json.JSONDecodeError):
|
|
1129
|
+
path.unlink()
|
|
1130
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1131
|
+
action_taken="removed unparseable preflight.json")
|
|
1132
|
+
if isinstance(state, dict) and feature and feature in state:
|
|
1133
|
+
state.pop(feature, None)
|
|
1134
|
+
if not state:
|
|
1135
|
+
path.unlink()
|
|
1136
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1137
|
+
action_taken=f"removed empty preflight.json")
|
|
1138
|
+
tmp = path.with_suffix(".json.tmp")
|
|
1139
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
1140
|
+
tmp.replace(path)
|
|
1141
|
+
return RepairResult(code=issue.code, success=True, feature=feature,
|
|
1142
|
+
action_taken=f"cleared preflight entry for '{feature}'")
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def repair_mcp_missing_in_workspace(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1146
|
+
"""Run ``install_mcp(workspace_root, reinstall=True)``."""
|
|
1147
|
+
from ..agent_setup import install_mcp
|
|
1148
|
+
result = install_mcp(workspace.config.root, reinstall=True)
|
|
1149
|
+
return RepairResult(
|
|
1150
|
+
code=issue.code,
|
|
1151
|
+
success=result.action != "skipped",
|
|
1152
|
+
action_taken=f"install_mcp: {result.action} at {result.path}",
|
|
1153
|
+
error=result.reason if result.action == "skipped" else None,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def repair_skill_missing(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1158
|
+
from ..agent_setup import install_skill
|
|
1159
|
+
result = install_skill()
|
|
1160
|
+
return RepairResult(
|
|
1161
|
+
code=issue.code,
|
|
1162
|
+
success=result.action != "skipped",
|
|
1163
|
+
action_taken=f"install_skill: {result.action} at {result.path}",
|
|
1164
|
+
error=result.reason if result.action == "skipped" else None,
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def repair_skill_stale(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1169
|
+
from ..agent_setup import install_skill
|
|
1170
|
+
result = install_skill(reinstall=True)
|
|
1171
|
+
return RepairResult(
|
|
1172
|
+
code=issue.code,
|
|
1173
|
+
success=result.action != "skipped",
|
|
1174
|
+
action_taken=f"install_skill --reinstall: {result.action} at {result.path}",
|
|
1175
|
+
error=result.reason if result.action == "skipped" else None,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def repair_mcp_orphans(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1180
|
+
"""SIGTERM listed orphan PIDs, then SIGKILL after a 2s grace.
|
|
1181
|
+
|
|
1182
|
+
Skips PIDs we don't own (EPERM) silently — there's no graceful
|
|
1183
|
+
recovery for a non-owned orphan and reporting one would just be noise.
|
|
1184
|
+
"""
|
|
1185
|
+
pids = list(issue.details.get("pids") or [])
|
|
1186
|
+
if not pids:
|
|
1187
|
+
return RepairResult(code=issue.code, success=True,
|
|
1188
|
+
action_taken="no orphans to reap")
|
|
1189
|
+
sent: list[int] = []
|
|
1190
|
+
failed: list[str] = []
|
|
1191
|
+
for pid in pids:
|
|
1192
|
+
try:
|
|
1193
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
1194
|
+
sent.append(int(pid))
|
|
1195
|
+
except ProcessLookupError:
|
|
1196
|
+
continue # already gone — fine
|
|
1197
|
+
except PermissionError:
|
|
1198
|
+
failed.append(f"{pid}: permission denied")
|
|
1199
|
+
continue
|
|
1200
|
+
except Exception as e: # noqa: BLE001
|
|
1201
|
+
failed.append(f"{pid}: {e}")
|
|
1202
|
+
continue
|
|
1203
|
+
# Grace period for clean shutdown, then SIGKILL stragglers.
|
|
1204
|
+
if sent:
|
|
1205
|
+
time.sleep(2.0)
|
|
1206
|
+
for pid in sent:
|
|
1207
|
+
try:
|
|
1208
|
+
os.kill(pid, 0) # probe — does the pid still exist?
|
|
1209
|
+
except ProcessLookupError:
|
|
1210
|
+
continue # gone, good
|
|
1211
|
+
try:
|
|
1212
|
+
os.kill(pid, signal.SIGKILL)
|
|
1213
|
+
except ProcessLookupError:
|
|
1214
|
+
continue
|
|
1215
|
+
except Exception as e: # noqa: BLE001
|
|
1216
|
+
failed.append(f"{pid}: SIGKILL: {e}")
|
|
1217
|
+
action = f"reaped {len(sent)} orphan(s)"
|
|
1218
|
+
if failed:
|
|
1219
|
+
return RepairResult(
|
|
1220
|
+
code=issue.code, success=bool(sent),
|
|
1221
|
+
action_taken=action, error="; ".join(failed),
|
|
1222
|
+
)
|
|
1223
|
+
return RepairResult(code=issue.code, success=True, action_taken=action)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def repair_vsix_duplicates(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1227
|
+
"""Remove all but the newest matching extension dir."""
|
|
1228
|
+
paths = [Path(p) for p in (issue.details.get("paths") or [])]
|
|
1229
|
+
if len(paths) <= 1:
|
|
1230
|
+
return RepairResult(code=issue.code, success=True,
|
|
1231
|
+
action_taken="nothing to clean")
|
|
1232
|
+
paths.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
|
1233
|
+
keep = paths[0]
|
|
1234
|
+
removed: list[str] = []
|
|
1235
|
+
errors: list[str] = []
|
|
1236
|
+
for p in paths[1:]:
|
|
1237
|
+
try:
|
|
1238
|
+
shutil.rmtree(p)
|
|
1239
|
+
removed.append(p.name)
|
|
1240
|
+
except OSError as e:
|
|
1241
|
+
errors.append(f"{p.name}: {e}")
|
|
1242
|
+
if errors:
|
|
1243
|
+
return RepairResult(
|
|
1244
|
+
code=issue.code, success=not removed and False or True,
|
|
1245
|
+
action_taken=f"kept {keep.name}; removed {len(removed)}",
|
|
1246
|
+
error="; ".join(errors),
|
|
1247
|
+
)
|
|
1248
|
+
return RepairResult(code=issue.code, success=True,
|
|
1249
|
+
action_taken=f"kept {keep.name}; removed {len(removed)} stale dirs")
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def repair_slot_entry_orphan(workspace: Workspace, issue: Issue) -> RepairResult:
|
|
1253
|
+
"""Drop the orphaned slots.json entry whose dir is gone."""
|
|
1254
|
+
state_path = workspace.config.root / ".canopy" / "state" / "slots.json"
|
|
1255
|
+
sid = (issue.details or {}).get("slot")
|
|
1256
|
+
if not sid:
|
|
1257
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1258
|
+
error="missing slot in issue details")
|
|
1259
|
+
if not state_path.exists():
|
|
1260
|
+
return RepairResult(code=issue.code, success=True,
|
|
1261
|
+
action_taken="slots.json already absent")
|
|
1262
|
+
try:
|
|
1263
|
+
data = json.loads(state_path.read_text())
|
|
1264
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1265
|
+
return RepairResult(code=issue.code, success=False, action_taken="",
|
|
1266
|
+
error=str(e))
|
|
1267
|
+
slots = data.get("slots")
|
|
1268
|
+
if not isinstance(slots, dict) or sid not in slots:
|
|
1269
|
+
return RepairResult(code=issue.code, success=True,
|
|
1270
|
+
action_taken=f"entry '{sid}' already absent from slots.json")
|
|
1271
|
+
slots.pop(sid)
|
|
1272
|
+
tmp = state_path.with_suffix(".json.tmp")
|
|
1273
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
1274
|
+
tmp.replace(state_path)
|
|
1275
|
+
return RepairResult(code=issue.code, success=True,
|
|
1276
|
+
action_taken=f"dropped slots.json entry for '{sid}'")
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
_REPAIRS: dict[str, Any] = {
|
|
1280
|
+
"heads_stale": repair_heads_stale,
|
|
1281
|
+
"active_feature_orphan": repair_active_feature_orphan,
|
|
1282
|
+
"active_feature_path_missing": repair_active_feature_path_missing,
|
|
1283
|
+
"worktree_orphan": repair_worktree_orphan,
|
|
1284
|
+
"worktree_missing": repair_worktree_missing,
|
|
1285
|
+
"hook_missing": repair_hook_missing,
|
|
1286
|
+
"hook_chained_unsafe": repair_hook_chained_unsafe,
|
|
1287
|
+
"preflight_stale": repair_preflight_stale,
|
|
1288
|
+
"mcp_missing_in_workspace": repair_mcp_missing_in_workspace,
|
|
1289
|
+
"skill_missing": repair_skill_missing,
|
|
1290
|
+
"skill_stale": repair_skill_stale,
|
|
1291
|
+
"mcp_orphans": repair_mcp_orphans,
|
|
1292
|
+
"vsix_duplicates": repair_vsix_duplicates,
|
|
1293
|
+
"slot_entry_orphan": repair_slot_entry_orphan,
|
|
1294
|
+
# cli_stale, mcp_stale, features_unknown_repo, branches_missing,
|
|
1295
|
+
# slot_dir_orphan, slot_branch_mismatch have no auto-fix —
|
|
1296
|
+
# repair returns surfaced advice via the issue's `fix_action` instead.
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
# ── Orchestrator ─────────────────────────────────────────────────────────
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def doctor(
|
|
1304
|
+
workspace: Workspace,
|
|
1305
|
+
*,
|
|
1306
|
+
fix: bool = False,
|
|
1307
|
+
fix_categories: list[str] | None = None,
|
|
1308
|
+
feature: str | None = None,
|
|
1309
|
+
clean_vsix: bool = False,
|
|
1310
|
+
) -> dict[str, Any]:
|
|
1311
|
+
"""Run the diagnostic suite, optionally repair, return a structured report.
|
|
1312
|
+
|
|
1313
|
+
Args:
|
|
1314
|
+
workspace: loaded ``Workspace``.
|
|
1315
|
+
fix: if True, run repairs for every auto-fixable issue (subject to
|
|
1316
|
+
``fix_categories`` and the ``clean_vsix`` gate).
|
|
1317
|
+
fix_categories: if set, only repair issues in these categories
|
|
1318
|
+
(state-integrity: heads/active_feature/worktrees/hooks/preflight/
|
|
1319
|
+
features/branches; install: cli/mcp/skill/vsix). Unknown
|
|
1320
|
+
categories are silently ignored. Implies ``fix=True``.
|
|
1321
|
+
feature: if set, scope feature-bearing checks to this feature only.
|
|
1322
|
+
Workspace-wide checks (heads_stale, hook_missing, install-
|
|
1323
|
+
staleness) still run in full.
|
|
1324
|
+
clean_vsix: required to repair ``vsix_duplicates`` even with ``fix=True``
|
|
1325
|
+
— vsix removal is destructive and opt-in.
|
|
1326
|
+
"""
|
|
1327
|
+
if fix_categories is not None:
|
|
1328
|
+
fix = True
|
|
1329
|
+
|
|
1330
|
+
all_issues: list[Issue] = []
|
|
1331
|
+
for code, (_category, fn) in _CHECKS.items():
|
|
1332
|
+
try:
|
|
1333
|
+
issues = fn(workspace)
|
|
1334
|
+
except Exception as e: # noqa: BLE001 — checks must never crash the doctor
|
|
1335
|
+
issues = [Issue(
|
|
1336
|
+
code=code,
|
|
1337
|
+
severity="warn",
|
|
1338
|
+
what=f"check raised: {e}",
|
|
1339
|
+
fix_action="report bug",
|
|
1340
|
+
auto_fixable=False,
|
|
1341
|
+
)]
|
|
1342
|
+
if feature is not None:
|
|
1343
|
+
issues = [i for i in issues if i.feature in (None, feature) or i.code in {
|
|
1344
|
+
"heads_stale", "hook_missing", "hook_chained_unsafe",
|
|
1345
|
+
"cli_stale", "mcp_stale", "mcp_missing_in_workspace",
|
|
1346
|
+
"mcp_orphans", "skill_missing", "skill_stale", "vsix_duplicates",
|
|
1347
|
+
}]
|
|
1348
|
+
all_issues.extend(issues)
|
|
1349
|
+
|
|
1350
|
+
fixed: list[dict[str, Any]] = []
|
|
1351
|
+
skipped: list[dict[str, Any]] = []
|
|
1352
|
+
if fix:
|
|
1353
|
+
for issue in all_issues:
|
|
1354
|
+
category, _ = _CHECKS[issue.code]
|
|
1355
|
+
if fix_categories is not None and category not in set(fix_categories):
|
|
1356
|
+
continue
|
|
1357
|
+
if issue.code == "vsix_duplicates" and not clean_vsix:
|
|
1358
|
+
skipped.append({
|
|
1359
|
+
**issue.to_dict(),
|
|
1360
|
+
"skip_reason": "vsix repair requires --clean-vsix",
|
|
1361
|
+
})
|
|
1362
|
+
continue
|
|
1363
|
+
repair_fn = _REPAIRS.get(issue.code)
|
|
1364
|
+
if repair_fn is None or not issue.auto_fixable:
|
|
1365
|
+
skipped.append({**issue.to_dict(), "skip_reason": "no auto-fix"})
|
|
1366
|
+
continue
|
|
1367
|
+
try:
|
|
1368
|
+
result = repair_fn(workspace, issue)
|
|
1369
|
+
except Exception as e: # noqa: BLE001
|
|
1370
|
+
result = RepairResult(code=issue.code, success=False,
|
|
1371
|
+
action_taken="", error=str(e))
|
|
1372
|
+
fixed.append(result.to_dict())
|
|
1373
|
+
|
|
1374
|
+
counts = {"errors": 0, "warnings": 0, "info": 0}
|
|
1375
|
+
for i in all_issues:
|
|
1376
|
+
if i.severity == "error":
|
|
1377
|
+
counts["errors"] += 1
|
|
1378
|
+
elif i.severity == "warn":
|
|
1379
|
+
counts["warnings"] += 1
|
|
1380
|
+
else:
|
|
1381
|
+
counts["info"] += 1
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
"workspace": workspace.config.name,
|
|
1385
|
+
"workspace_root": str(workspace.config.root),
|
|
1386
|
+
"checked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1387
|
+
"issues": [i.to_dict() for i in all_issues],
|
|
1388
|
+
"summary": counts,
|
|
1389
|
+
"fixed": fixed,
|
|
1390
|
+
"skipped": skipped,
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _read_raw_active_feature(workspace_root: Path) -> dict[str, Any] | None:
|
|
1398
|
+
"""Read .canopy/state/active_feature.json without the stale-path filter
|
|
1399
|
+
that ``actions.active_feature.read_active`` applies — we WANT to see
|
|
1400
|
+
stale paths so we can report them.
|
|
1401
|
+
"""
|
|
1402
|
+
path = workspace_root / ".canopy" / "state" / "active_feature.json"
|
|
1403
|
+
if not path.exists():
|
|
1404
|
+
return None
|
|
1405
|
+
try:
|
|
1406
|
+
data = json.loads(path.read_text())
|
|
1407
|
+
except (OSError, json.JSONDecodeError):
|
|
1408
|
+
return None
|
|
1409
|
+
if not isinstance(data, dict):
|
|
1410
|
+
return None
|
|
1411
|
+
return data
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _load_features_raw(workspace_root: Path) -> dict[str, Any]:
|
|
1415
|
+
path = workspace_root / ".canopy" / "features.json"
|
|
1416
|
+
if not path.exists():
|
|
1417
|
+
return {}
|
|
1418
|
+
try:
|
|
1419
|
+
data = json.loads(path.read_text())
|
|
1420
|
+
except (OSError, json.JSONDecodeError):
|
|
1421
|
+
return {}
|
|
1422
|
+
return data if isinstance(data, dict) else {}
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def _save_features_raw(workspace_root: Path, features: dict[str, Any]) -> None:
|
|
1426
|
+
path = workspace_root / ".canopy" / "features.json"
|
|
1427
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1428
|
+
tmp = path.with_suffix(".json.tmp")
|
|
1429
|
+
tmp.write_text(json.dumps(features, indent=2))
|
|
1430
|
+
tmp.replace(path)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _read_binary_version(binary_path: str) -> str | None:
|
|
1434
|
+
"""Run ``<binary> --version`` and return the version token, or None."""
|
|
1435
|
+
try:
|
|
1436
|
+
out = subprocess.run(
|
|
1437
|
+
[binary_path, "--version"],
|
|
1438
|
+
capture_output=True, text=True, check=False, timeout=5,
|
|
1439
|
+
)
|
|
1440
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1441
|
+
return None
|
|
1442
|
+
if out.returncode != 0:
|
|
1443
|
+
return None
|
|
1444
|
+
parts = out.stdout.strip().split()
|
|
1445
|
+
return parts[-1] if parts else None
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _is_older(installed: str, source: str) -> bool:
|
|
1449
|
+
"""Return True iff ``installed < source`` under loose semver comparison.
|
|
1450
|
+
|
|
1451
|
+
Falls back to lexical comparison for non-numeric components. Equality
|
|
1452
|
+
or "newer than source" returns False.
|
|
1453
|
+
"""
|
|
1454
|
+
try:
|
|
1455
|
+
a = tuple(int(x) for x in installed.split(".")[:3])
|
|
1456
|
+
b = tuple(int(x) for x in source.split(".")[:3])
|
|
1457
|
+
return a < b
|
|
1458
|
+
except (ValueError, AttributeError):
|
|
1459
|
+
return installed != source and installed < source
|