canopy-cli 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,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