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,1256 @@
1
+ """
2
+ Feature lane lifecycle management.
3
+
4
+ A feature lane is a coordination primitive that spans multiple repos.
5
+ It maps to real Git branches — one per participating repo — with
6
+ metadata tracked in .canopy/features.json.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field, asdict
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from ..workspace.workspace import Workspace
16
+ from ..git import repo as git
17
+ from ..git.multi import create_branch_all, cross_repo_diff, find_type_overlaps
18
+ from ..providers import get_issue_provider
19
+ from ..actions import slots as slots_mod
20
+
21
+ # Default directory for worktrees, relative to workspace root. In Wave 3.0
22
+ # this contains generic numbered slot dirs (worktree-1, worktree-2, ...)
23
+ # whose feature occupancy is tracked in .canopy/state/slots.json.
24
+ _WORKTREE_DIR = ".canopy/worktrees"
25
+
26
+
27
+ class WorktreeLimitError(Exception):
28
+ """Worktree limit would be exceeded."""
29
+ def __init__(self, message: str, current: int = 0, limit: int = 0, stale: list[dict] | None = None):
30
+ super().__init__(message)
31
+ self.current = current
32
+ self.limit = limit
33
+ self.stale = stale or []
34
+
35
+
36
+ @dataclass
37
+ class FeatureLane:
38
+ """Metadata and live state for a feature lane."""
39
+ name: str
40
+ repos: list[str] # participating repo names
41
+ created_at: str = "" # ISO timestamp
42
+ status: str = "active" # active | merged | abandoned
43
+
44
+ # Optional integration links
45
+ linear_issue: str = "" # e.g. "ENG-123"
46
+ linear_title: str = "" # e.g. "Add payment processing"
47
+ linear_url: str = "" # e.g. "https://linear.app/..."
48
+
49
+ # Optional per-repo branch override. When unset, ``branch_for(repo)``
50
+ # returns the feature name (the historical default). When set,
51
+ # consumers should always go through ``branch_for`` to get the right
52
+ # branch name per repo. Used for cases like ``auth-flow`` (api) vs
53
+ # ``auth-flow-v2`` (ui) where the same feature has different branch
54
+ # names per repo.
55
+ branches: dict[str, str] = field(default_factory=dict)
56
+
57
+ # Populated at query time (not persisted)
58
+ repo_states: dict[str, dict] = field(default_factory=dict)
59
+
60
+ def branch_for(self, repo: str) -> str:
61
+ """Return the expected branch name for ``repo`` in this lane.
62
+
63
+ Falls back to the feature name if no per-repo override exists.
64
+ """
65
+ return self.branches.get(repo) or self.name
66
+
67
+ def to_dict(self) -> dict:
68
+ d = {
69
+ "name": self.name,
70
+ "repos": self.repos,
71
+ "created_at": self.created_at,
72
+ "status": self.status,
73
+ "repo_states": self.repo_states,
74
+ }
75
+ if self.linear_issue:
76
+ d["linear_issue"] = self.linear_issue
77
+ d["linear_title"] = self.linear_title
78
+ d["linear_url"] = self.linear_url
79
+ if self.branches:
80
+ d["branches"] = dict(self.branches)
81
+ return d
82
+
83
+
84
+ class FeatureCoordinator:
85
+ """Manages feature lane lifecycle across a workspace."""
86
+
87
+ def __init__(self, workspace: Workspace):
88
+ self.workspace = workspace
89
+ self._store_path = workspace.config.root / ".canopy" / "features.json"
90
+
91
+ def _resolve_name(self, name: str) -> str:
92
+ """Resolve a short alias to a full feature name.
93
+
94
+ Supports:
95
+ - Exact match (returned as-is)
96
+ - Linear issue prefix (e.g. "ENG-412" → "ENG-412-add-oauth2-login")
97
+ - Unique prefix match (e.g. "ENG-412" matches if only one feature starts with it)
98
+
99
+ Raises ValueError if the alias is ambiguous (matches multiple features).
100
+ Returns the original name if no match is found (allows implicit features).
101
+ """
102
+ features = self._load_features()
103
+
104
+ # Exact match — fast path
105
+ if name in features:
106
+ return name
107
+
108
+ # Prefix match: check if name is a prefix of exactly one feature
109
+ matches = [f for f in features if f.startswith(name)]
110
+
111
+ # Also check linear_issue field for issue-ID-only lookups
112
+ if not matches:
113
+ matches = [
114
+ f for f, data in features.items()
115
+ if data.get("linear_issue", "").upper() == name.upper()
116
+ ]
117
+
118
+ if len(matches) == 1:
119
+ return matches[0]
120
+ elif len(matches) > 1:
121
+ raise ValueError(
122
+ f"Ambiguous alias '{name}' matches: {', '.join(sorted(matches))}"
123
+ )
124
+
125
+ # No match in features.json — return as-is for implicit feature detection
126
+ return name
127
+
128
+ def create(
129
+ self,
130
+ name: str,
131
+ repos: list[str] | None = None,
132
+ use_worktrees: bool = False,
133
+ worktree_base: Path | None = None,
134
+ linear_issue: str = "",
135
+ linear_title: str = "",
136
+ linear_url: str = "",
137
+ ) -> FeatureLane:
138
+ """Create a new feature lane.
139
+
140
+ Creates matching branches in all (or specified) repos and
141
+ records the feature in .canopy/features.json.
142
+
143
+ Args:
144
+ name: Feature/branch name.
145
+ repos: Subset of repos (default: all).
146
+ use_worktrees: If True, create linked worktrees instead of
147
+ just branches. Each repo gets a worktree at
148
+ <worktree_base>/<feature>/<repo_name>.
149
+ worktree_base: Base directory for worktrees. Defaults to
150
+ <workspace_root>/.canopy/worktrees.
151
+ """
152
+ target_repos = repos or [r.config.name for r in self.workspace.repos]
153
+
154
+ # Validate repos exist
155
+ known = {r.config.name for r in self.workspace.repos}
156
+ unknown = set(target_repos) - known
157
+ if unknown:
158
+ raise ValueError(f"Unknown repos: {', '.join(sorted(unknown))}")
159
+
160
+ worktree_paths: dict[str, str] = {}
161
+ allocated_slot: str | None = None
162
+
163
+ if use_worktrees:
164
+ # Wave 3.0: allocate a slot from .canopy/state/slots.json.
165
+ # The config's ``slots`` field is the warm-slot cap (canonical
166
+ # is separate). If all slots are full, raise WorktreeLimitError
167
+ # so the CLI / MCP can surface a fix action.
168
+ limit = self.workspace.config.slots
169
+ slot_state = slots_mod.read_state(self.workspace) or slots_mod.SlotState(
170
+ slot_count=limit,
171
+ )
172
+ # Honor the canopy.toml cap even if state was persisted with a
173
+ # different slot_count earlier.
174
+ slot_state.slot_count = limit
175
+
176
+ allocated_slot = slots_mod.allocate_slot(slot_state)
177
+ if allocated_slot is None:
178
+ stale = self._find_stale_worktrees()
179
+ current = len(slot_state.slots)
180
+ raise WorktreeLimitError(
181
+ f"Worktree limit reached ({current}/{limit}). "
182
+ f"Clean up with `canopy done <feature>` or raise the "
183
+ f"limit with `canopy config slots {limit + 1}`.",
184
+ current=current,
185
+ limit=limit,
186
+ stale=stale,
187
+ )
188
+
189
+ base = worktree_base or (self.workspace.config.root / _WORKTREE_DIR)
190
+ feature_dir = base / allocated_slot
191
+ feature_dir.mkdir(parents=True, exist_ok=True)
192
+
193
+ results: dict[str, bool | str] = {}
194
+ for repo_name in target_repos:
195
+ state = self.workspace.get_repo(repo_name)
196
+ wt_dest = feature_dir / repo_name
197
+ try:
198
+ git.worktree_add(
199
+ state.abs_path, wt_dest, name, create_branch=True,
200
+ )
201
+ results[repo_name] = True
202
+ worktree_paths[repo_name] = str(wt_dest)
203
+ except git.GitError as e:
204
+ results[repo_name] = str(e)
205
+
206
+ failed = {r: msg for r, msg in results.items() if msg is not True}
207
+ if len(failed) == len(target_repos):
208
+ raise RuntimeError(
209
+ f"Failed to create worktrees in all repos: {failed}"
210
+ )
211
+
212
+ # Persist the slot occupancy + last_touched on success.
213
+ now = slots_mod.now_iso()
214
+ slot_state.slots[allocated_slot] = slots_mod.SlotEntry(
215
+ feature=name, occupied_at=now,
216
+ )
217
+ slot_state.last_touched[name] = now
218
+ slots_mod.write_state(self.workspace, slot_state)
219
+ else:
220
+ # Just create branches
221
+ results = create_branch_all(self.workspace, name, target_repos)
222
+ failed = {r: msg for r, msg in results.items() if msg is not True}
223
+ if len(failed) == len(target_repos):
224
+ raise RuntimeError(
225
+ f"Failed to create branch in all repos: {failed}"
226
+ )
227
+
228
+ # Record the feature
229
+ lane = FeatureLane(
230
+ name=name,
231
+ repos=target_repos,
232
+ created_at=datetime.now(timezone.utc).isoformat(),
233
+ status="active",
234
+ linear_issue=linear_issue,
235
+ linear_title=linear_title,
236
+ linear_url=linear_url,
237
+ )
238
+
239
+ features = self._load_features()
240
+ feature_data: dict = {
241
+ "repos": lane.repos,
242
+ "created_at": lane.created_at,
243
+ "status": lane.status,
244
+ }
245
+ if worktree_paths:
246
+ feature_data["worktree_paths"] = worktree_paths
247
+ feature_data["use_worktrees"] = True
248
+ if allocated_slot:
249
+ feature_data["slot_id"] = allocated_slot
250
+ if linear_issue:
251
+ feature_data["linear_issue"] = linear_issue
252
+ feature_data["linear_title"] = linear_title
253
+ feature_data["linear_url"] = linear_url
254
+ features[name] = feature_data
255
+ self._save_features(features)
256
+
257
+ return lane
258
+
259
+ def list_active(self) -> list[FeatureLane]:
260
+ """List all active feature lanes with live state."""
261
+ features = self._load_features()
262
+ lanes = []
263
+
264
+ for name, data in features.items():
265
+ if data.get("status", "active") != "active":
266
+ continue
267
+ lane = FeatureLane(
268
+ name=name,
269
+ repos=data["repos"],
270
+ created_at=data.get("created_at", ""),
271
+ status=data.get("status", "active"),
272
+ linear_issue=data.get("linear_issue", ""),
273
+ linear_title=data.get("linear_title", ""),
274
+ linear_url=data.get("linear_url", ""),
275
+ branches=dict(data.get("branches") or {}),
276
+ )
277
+ self._enrich_lane(lane)
278
+ lanes.append(lane)
279
+
280
+ # Also detect implicit features (branches in 2+ repos not in features.json)
281
+ explicit_names = set(features.keys())
282
+ for branch_name in self.workspace.active_features():
283
+ if branch_name not in explicit_names:
284
+ # Find which repos have this branch
285
+ repos_with = []
286
+ for state in self.workspace.repos:
287
+ try:
288
+ if git.branch_exists(state.abs_path, branch_name):
289
+ repos_with.append(state.config.name)
290
+ except Exception:
291
+ pass
292
+ if len(repos_with) >= 2:
293
+ lane = FeatureLane(
294
+ name=branch_name,
295
+ repos=repos_with,
296
+ status="active",
297
+ )
298
+ self._enrich_lane(lane)
299
+ lanes.append(lane)
300
+
301
+ return lanes
302
+
303
+ def status(self, name: str) -> FeatureLane:
304
+ """Get detailed status for a feature lane."""
305
+ name = self._resolve_name(name)
306
+ features = self._load_features()
307
+ if name in features:
308
+ data = features[name]
309
+ lane = FeatureLane(
310
+ name=name,
311
+ repos=data["repos"],
312
+ created_at=data.get("created_at", ""),
313
+ status=data.get("status", "active"),
314
+ linear_issue=data.get("linear_issue", ""),
315
+ linear_title=data.get("linear_title", ""),
316
+ linear_url=data.get("linear_url", ""),
317
+ )
318
+ else:
319
+ # Implicit feature
320
+ repos = []
321
+ for state in self.workspace.repos:
322
+ if git.branch_exists(state.abs_path, name):
323
+ repos.append(state.config.name)
324
+ if not repos:
325
+ raise ValueError(f"Feature '{name}' not found")
326
+ lane = FeatureLane(name=name, repos=repos, status="active")
327
+
328
+ self._enrich_lane(lane)
329
+ return lane
330
+
331
+ def link_linear_issue(self, feature: str, issue: str) -> FeatureLane:
332
+ """Attach a Linear issue to an existing feature lane.
333
+
334
+ Fetches issue data via the Linear MCP server and writes linear_issue,
335
+ linear_title, linear_url onto the lane's record in features.json.
336
+ Overwrites any previously linked issue.
337
+
338
+ Args:
339
+ feature: Feature lane name or alias.
340
+ issue: Linear issue identifier (e.g. "ENG-412").
341
+
342
+ Returns:
343
+ The updated FeatureLane (with enriched repo_states).
344
+
345
+ Raises:
346
+ ValueError: Feature not found in features.json.
347
+ ProviderNotConfigured: Issue provider isn't set up.
348
+ IssueNotFoundError: Issue can't be resolved.
349
+ """
350
+ # M5: route through the provider registry. Method name kept as
351
+ # link_linear_issue for backward compat (callers + the MCP tool
352
+ # name); the linked issue can be from any configured provider.
353
+ name = self._resolve_name(feature)
354
+ features = self._load_features()
355
+ if name not in features:
356
+ raise ValueError(
357
+ f"Feature '{name}' not found in features.json — "
358
+ f"link_linear_issue only works on explicitly created lanes."
359
+ )
360
+
361
+ provider = get_issue_provider(self.workspace)
362
+ issue_data = provider.get_issue(issue)
363
+ features[name]["linear_issue"] = issue_data.identifier or issue
364
+ features[name]["linear_title"] = issue_data.title or ""
365
+ features[name]["linear_url"] = issue_data.url or ""
366
+ self._save_features(features)
367
+
368
+ return self.status(name)
369
+
370
+ def diff(self, name: str) -> dict:
371
+ """Get aggregate diff for a feature lane across repos."""
372
+ name = self._resolve_name(name)
373
+ diff_data = cross_repo_diff(self.workspace, name)
374
+ overlaps = find_type_overlaps(self.workspace, name)
375
+
376
+ # Summary
377
+ total_files = sum(d["files_changed"] for d in diff_data.values())
378
+ total_ins = sum(d["insertions"] for d in diff_data.values())
379
+ total_del = sum(d["deletions"] for d in diff_data.values())
380
+ participating = sum(1 for d in diff_data.values() if d.get("has_branch"))
381
+
382
+ return {
383
+ "feature": name,
384
+ "repos": diff_data,
385
+ "summary": {
386
+ "participating_repos": participating,
387
+ "total_repos": len(diff_data),
388
+ "total_files_changed": total_files,
389
+ "total_insertions": total_ins,
390
+ "total_deletions": total_del,
391
+ },
392
+ "type_overlaps": overlaps,
393
+ }
394
+
395
+ def feature_changes(self, name: str) -> dict:
396
+ """Get per-file change status (M/A/D/?) for each repo in a feature.
397
+
398
+ Includes uncommitted changes — uses the worktree path when one
399
+ exists so the listing matches what the user is editing.
400
+
401
+ Returns:
402
+ {
403
+ "feature": str,
404
+ "repos": {
405
+ "<repo>": {
406
+ "has_branch": bool,
407
+ "path": str, # repo or worktree path used
408
+ "default_branch": str,
409
+ "changes": [{path, status}, ...],
410
+ "error": str | None,
411
+ }
412
+ }
413
+ }
414
+ """
415
+ name = self._resolve_name(name)
416
+ lane = self.status(name)
417
+ result: dict[str, dict] = {}
418
+
419
+ for repo_name in lane.repos:
420
+ repo_state = lane.repo_states.get(repo_name, {})
421
+ try:
422
+ state = self.workspace.get_repo(repo_name)
423
+ except KeyError:
424
+ result[repo_name] = {"error": "repo not found"}
425
+ continue
426
+
427
+ base = repo_state.get("default_branch") or state.config.default_branch
428
+ wt_path = repo_state.get("worktree_path")
429
+ scan_path = Path(wt_path) if wt_path else state.abs_path
430
+
431
+ if not repo_state.get("has_branch"):
432
+ result[repo_name] = {
433
+ "has_branch": False,
434
+ "path": str(scan_path),
435
+ "default_branch": base,
436
+ "changes": [],
437
+ }
438
+ continue
439
+
440
+ try:
441
+ changes = git.changed_files_with_status(scan_path, name, base)
442
+ result[repo_name] = {
443
+ "has_branch": True,
444
+ "path": str(scan_path),
445
+ "default_branch": base,
446
+ "changes": changes,
447
+ }
448
+ except git.GitError as e:
449
+ result[repo_name] = {
450
+ "has_branch": True,
451
+ "path": str(scan_path),
452
+ "default_branch": base,
453
+ "changes": [],
454
+ "error": str(e),
455
+ }
456
+
457
+ return {"feature": name, "repos": result}
458
+
459
+ def merge_readiness(self, name: str) -> dict:
460
+ """Check if a feature lane is ready to merge.
461
+
462
+ Checks:
463
+ - All repos are clean (no uncommitted changes)
464
+ - All branches are up to date with default
465
+ - No type overlaps detected
466
+ """
467
+ name = self._resolve_name(name)
468
+ lane = self.status(name)
469
+ issues = []
470
+
471
+ for repo_name, state in lane.repo_states.items():
472
+ if state.get("dirty"):
473
+ issues.append(f"{repo_name}: has uncommitted changes")
474
+ if state.get("behind", 0) > 0:
475
+ issues.append(
476
+ f"{repo_name}: {state['behind']} commits behind "
477
+ f"{state.get('default_branch', 'default')}"
478
+ )
479
+
480
+ overlaps = find_type_overlaps(self.workspace, name)
481
+ if overlaps:
482
+ for o in overlaps:
483
+ issues.append(
484
+ f"Type overlap: '{o['file_pattern']}' modified in "
485
+ f"{', '.join(o['repos'])}"
486
+ )
487
+
488
+ return {
489
+ "feature": name,
490
+ "ready": len(issues) == 0,
491
+ "issues": issues,
492
+ }
493
+
494
+ def resolve_paths(self, name: str) -> dict[str, str]:
495
+ """Get the working directory path for each repo in a feature lane.
496
+
497
+ For each repo, returns the best path to work in:
498
+ - If the feature occupies a warm slot → the slot's repo subdir
499
+ (``.canopy/worktrees/worktree-N/<repo>``)
500
+ - If the branch is checked out in a worktree (legacy/ad-hoc) →
501
+ that worktree path
502
+ - If the branch is the current branch in the repo → the repo path
503
+ - Otherwise → the repo path (caller may need to checkout first)
504
+
505
+ This is used by IDE launchers to know which directories to open.
506
+ """
507
+ name = self._resolve_name(name)
508
+ lane = self.status(name)
509
+ paths: dict[str, str] = {}
510
+
511
+ # Wave 3.0: prefer the slot path when the feature is warm. This is
512
+ # the authoritative source for warm features.
513
+ slot_id = slots_mod.slot_for_feature(self.workspace, name)
514
+
515
+ for repo_name in lane.repos:
516
+ try:
517
+ state = self.workspace.get_repo(repo_name)
518
+ except KeyError:
519
+ continue
520
+
521
+ repo_state = lane.repo_states.get(repo_name, {})
522
+
523
+ # Priority 1: slot path (Wave 3.0 canonical-slot model)
524
+ if slot_id is not None:
525
+ slot_path = slots_mod.slot_worktree_path(
526
+ self.workspace, slot_id, repo_name,
527
+ )
528
+ if slot_path.exists():
529
+ paths[repo_name] = str(slot_path)
530
+ continue
531
+ # Priority 2: worktree path discovered by git (fallback)
532
+ if repo_state.get("worktree_path"):
533
+ paths[repo_name] = repo_state["worktree_path"]
534
+ # Priority 3: repo is on this branch
535
+ elif state.current_branch == name:
536
+ paths[repo_name] = str(state.abs_path)
537
+ # Priority 4: branch exists but not checked out — use repo path
538
+ elif repo_state.get("has_branch"):
539
+ paths[repo_name] = str(state.abs_path)
540
+
541
+ return paths
542
+
543
+ def _enrich_lane(self, lane: FeatureLane) -> None:
544
+ """Populate repo_states with live Git data."""
545
+ for repo_name in lane.repos:
546
+ try:
547
+ state = self.workspace.get_repo(repo_name)
548
+ except KeyError:
549
+ lane.repo_states[repo_name] = {"error": "repo not found"}
550
+ continue
551
+
552
+ base = state.config.default_branch
553
+ has_branch = git.branch_exists(state.abs_path, lane.name)
554
+
555
+ if not has_branch:
556
+ lane.repo_states[repo_name] = {
557
+ "has_branch": False,
558
+ "ahead": 0,
559
+ "behind": 0,
560
+ "dirty": False,
561
+ "changed_files": [],
562
+ }
563
+ continue
564
+
565
+ try:
566
+ ahead, behind = git.divergence(
567
+ state.abs_path, lane.name, base
568
+ )
569
+ files = git.changed_files(state.abs_path, lane.name, base)
570
+ dirty = state.is_dirty if state.current_branch == lane.name else False
571
+
572
+ repo_state: dict = {
573
+ "has_branch": True,
574
+ "ahead": ahead,
575
+ "behind": behind,
576
+ "dirty": dirty,
577
+ "changed_files": files,
578
+ "changed_file_count": len(files),
579
+ "default_branch": base,
580
+ }
581
+
582
+ # Check if branch is checked out in a worktree
583
+ wt_path = git.worktree_for_branch(state.abs_path, lane.name)
584
+ if wt_path:
585
+ repo_state["worktree_path"] = wt_path
586
+
587
+ lane.repo_states[repo_name] = repo_state
588
+ except git.GitError as e:
589
+ lane.repo_states[repo_name] = {
590
+ "has_branch": True,
591
+ "error": str(e),
592
+ }
593
+
594
+ def worktrees_live(self) -> dict:
595
+ """Live scan of all worktrees across the workspace.
596
+
597
+ Wave 3.0: returns slot-keyed view of warm features. Iterates the
598
+ ``slots`` map from ``.canopy/state/slots.json`` (not feature-named
599
+ directories) and enriches each slot's repo subdirs with live git
600
+ state. Also includes git-level worktree info per main repo.
601
+
602
+ Returns:
603
+ {
604
+ "slots": {
605
+ "worktree-1": {
606
+ "feature": "<feature>",
607
+ "repos": {
608
+ "<repo>": {
609
+ "path": str,
610
+ "branch": str,
611
+ "dirty": bool,
612
+ "dirty_count": int,
613
+ "dirty_files": [...],
614
+ "ahead": int,
615
+ "behind": int,
616
+ "default_branch": str,
617
+ }
618
+ }
619
+ }
620
+ },
621
+ "repos": {
622
+ "<repo>": {
623
+ "main_path": str,
624
+ "worktrees": [{"path": str, "branch": str, "sha": str}]
625
+ }
626
+ }
627
+ }
628
+ """
629
+ # ── Part 1: walk the slots map from slots.json ────────────────
630
+ slots: dict = {}
631
+ slot_state = slots_mod.read_state(self.workspace)
632
+ if slot_state is not None:
633
+ for slot_id, entry in sorted(slot_state.slots.items()):
634
+ feat_name = entry.feature
635
+ slot_dir = (
636
+ self.workspace.config.root / _WORKTREE_DIR / slot_id
637
+ )
638
+ if not slot_dir.is_dir():
639
+ continue
640
+ repos_info: dict = {}
641
+ for repo_dir in sorted(slot_dir.iterdir()):
642
+ if not repo_dir.is_dir():
643
+ continue
644
+ repo_name = repo_dir.name
645
+ repo_entry: dict = {"path": str(repo_dir)}
646
+ try:
647
+ repo_entry["branch"] = git.current_branch(repo_dir)
648
+ porcelain = git.status_porcelain(repo_dir)
649
+ repo_entry["dirty"] = len(porcelain) > 0
650
+ repo_entry["dirty_count"] = len(porcelain)
651
+ repo_entry["dirty_files"] = [
652
+ f.get("path", "") for f in porcelain
653
+ ]
654
+ default_branch = "main"
655
+ try:
656
+ state = self.workspace.get_repo(repo_name)
657
+ default_branch = state.config.default_branch
658
+ except KeyError:
659
+ pass
660
+ repo_entry["default_branch"] = default_branch
661
+ try:
662
+ ahead, behind = git.divergence(
663
+ repo_dir, repo_entry["branch"], default_branch,
664
+ )
665
+ repo_entry["ahead"] = ahead
666
+ repo_entry["behind"] = behind
667
+ except git.GitError:
668
+ repo_entry["ahead"] = 0
669
+ repo_entry["behind"] = 0
670
+ except git.GitError as e:
671
+ repo_entry["error"] = str(e)
672
+ repos_info[repo_name] = repo_entry
673
+ slots[slot_id] = {"feature": feat_name, "repos": repos_info}
674
+
675
+ # ── Part 2: git-level worktree info per main repo ────────────
676
+ repos_wt: dict = {}
677
+ for state in self.workspace.repos:
678
+ if not state.abs_path.exists():
679
+ continue
680
+ worktrees = git.worktree_list(state.abs_path)
681
+ repos_wt[state.config.name] = {
682
+ "main_path": str(state.abs_path),
683
+ "worktrees": worktrees,
684
+ }
685
+
686
+ return {
687
+ "slots": slots,
688
+ "repos": repos_wt,
689
+ }
690
+
691
+ def done(self, name: str, force: bool = False) -> dict:
692
+ """Clean up a feature lane: remove worktrees, delete branches, archive.
693
+
694
+ Steps:
695
+ 1. Check if worktrees are dirty (fail unless --force)
696
+ 2. Remove worktree directories
697
+ 3. Delete local branches
698
+ 4. Mark feature as 'done' in features.json
699
+
700
+ Args:
701
+ name: Feature lane name (or alias/Linear ID).
702
+ force: If True, remove even with dirty worktrees.
703
+
704
+ Returns:
705
+ {
706
+ "feature": str,
707
+ "worktrees_removed": {repo: path},
708
+ "branches_deleted": {repo: "ok" | error},
709
+ "archived": bool,
710
+ }
711
+ """
712
+ name = self._resolve_name(name)
713
+ features = self._load_features()
714
+ feature_data = features.get(name, {})
715
+ repos = feature_data.get("repos", [])
716
+
717
+ # If not in features.json, try to find it as an implicit feature
718
+ if not repos:
719
+ for state in self.workspace.repos:
720
+ if git.branch_exists(state.abs_path, name):
721
+ repos.append(state.config.name)
722
+ if not repos:
723
+ raise ValueError(f"Feature '{name}' not found")
724
+
725
+ worktrees_removed: dict[str, str] = {}
726
+ branches_deleted: dict[str, str] = {}
727
+
728
+ # ── Step 1+2: Remove worktrees from the feature's slot ──
729
+ # Wave 3.0: look up the slot in .canopy/state/slots.json. The
730
+ # worktree dir is .canopy/worktrees/<slot_id>/, not /<feature>/.
731
+ slot_id = slots_mod.slot_for_feature(self.workspace, name)
732
+ wt_base: Path | None = None
733
+ if slot_id is not None:
734
+ wt_base = (
735
+ self.workspace.config.root / _WORKTREE_DIR / slot_id
736
+ )
737
+ if wt_base is not None and wt_base.is_dir():
738
+ for repo_dir in sorted(wt_base.iterdir()):
739
+ if not repo_dir.is_dir():
740
+ continue
741
+ repo_name = repo_dir.name
742
+
743
+ # Check dirty state
744
+ if not force:
745
+ try:
746
+ porcelain = git.status_porcelain(repo_dir)
747
+ if porcelain:
748
+ raise ValueError(
749
+ f"Worktree '{slot_id}/{repo_name}' has uncommitted changes. "
750
+ f"Use --force to remove anyway."
751
+ )
752
+ except git.GitError:
753
+ pass
754
+
755
+ # Find the main repo to remove worktree from
756
+ try:
757
+ state = self.workspace.get_repo(repo_name)
758
+ git.worktree_remove(state.abs_path, repo_dir, force=force)
759
+ worktrees_removed[repo_name] = str(repo_dir)
760
+ except (KeyError, git.GitError) as e:
761
+ # If git worktree remove fails, try to clean up manually
762
+ import shutil
763
+ try:
764
+ shutil.rmtree(repo_dir)
765
+ worktrees_removed[repo_name] = str(repo_dir)
766
+ except OSError:
767
+ worktrees_removed[repo_name] = f"error: {e}"
768
+
769
+ # Remove the slot directory if empty
770
+ try:
771
+ wt_base.rmdir()
772
+ except OSError:
773
+ pass
774
+
775
+ # ── Step 2b: Drop the slot entry from slots.json ──
776
+ if slot_id is not None:
777
+ slot_state = slots_mod.read_state(self.workspace)
778
+ if slot_state is not None:
779
+ slot_state.slots.pop(slot_id, None)
780
+ # If canonical pointed at this feature (wind-down), clear it.
781
+ if (
782
+ slot_state.canonical is not None
783
+ and slot_state.canonical.feature == name
784
+ ):
785
+ slot_state.canonical = None
786
+ slot_state.last_touched.pop(name, None)
787
+ slots_mod.write_state(self.workspace, slot_state)
788
+
789
+ # ── Step 3: Delete local branches ──
790
+ for repo_name in repos:
791
+ try:
792
+ state = self.workspace.get_repo(repo_name)
793
+ except KeyError:
794
+ branches_deleted[repo_name] = "repo not found"
795
+ continue
796
+
797
+ if not git.branch_exists(state.abs_path, name):
798
+ branches_deleted[repo_name] = "no branch"
799
+ continue
800
+
801
+ # Don't delete if it's the current branch
802
+ current = git.current_branch(state.abs_path)
803
+ if current == name:
804
+ # Switch to default branch first
805
+ try:
806
+ git.checkout(state.abs_path, state.config.default_branch)
807
+ except git.GitError as e:
808
+ branches_deleted[repo_name] = f"could not switch away: {e}"
809
+ continue
810
+
811
+ try:
812
+ git.delete_branch(state.abs_path, name, force=force)
813
+ branches_deleted[repo_name] = "ok"
814
+ except git.GitError as e:
815
+ branches_deleted[repo_name] = str(e)
816
+
817
+ # ── Step 4: Archive in features.json ──
818
+ archived = False
819
+ if name in features:
820
+ features[name]["status"] = "done"
821
+ # Remove worktree paths since they no longer exist
822
+ features[name].pop("worktree_paths", None)
823
+ features[name].pop("use_worktrees", None)
824
+ features[name].pop("slot_id", None)
825
+ self._save_features(features)
826
+ archived = True
827
+
828
+ # ── Step 5: Drop canonical pointer if this feature is canonical ──
829
+ active_cleared = False
830
+ try:
831
+ state = slots_mod.read_state(self.workspace)
832
+ if state and state.canonical and state.canonical.feature == name:
833
+ state.canonical = None
834
+ slots_mod.write_state(self.workspace, state)
835
+ active_cleared = True
836
+ except Exception:
837
+ pass
838
+
839
+ return {
840
+ "feature": name,
841
+ "worktrees_removed": worktrees_removed,
842
+ "branches_deleted": branches_deleted,
843
+ "archived": archived,
844
+ "active_cleared": active_cleared,
845
+ }
846
+
847
+ def review_status(self, name: str) -> dict:
848
+ """Check if PRs exist for a feature lane across repos.
849
+
850
+ For each repo, resolves the remote URL to owner/repo, then queries
851
+ GitHub MCP for an open PR matching the feature branch.
852
+
853
+ Returns:
854
+ {
855
+ "feature": str,
856
+ "has_prs": bool,
857
+ "repos": {
858
+ "<repo>": {
859
+ "branch": str,
860
+ "owner": str,
861
+ "repo_name": str,
862
+ "pr": {number, title, url, state, head_branch} | None,
863
+ "error": str (optional)
864
+ }
865
+ }
866
+ }
867
+
868
+ Raises:
869
+ ValueError: If the feature doesn't exist.
870
+ GitHubNotConfiguredError: If GitHub MCP is not configured.
871
+ """
872
+ from ..integrations.github import (
873
+ is_github_configured,
874
+ find_pull_request,
875
+ _extract_owner_repo,
876
+ GitHubNotConfiguredError,
877
+ )
878
+
879
+ name = self._resolve_name(name)
880
+
881
+ if not is_github_configured(self.workspace.config.root):
882
+ raise GitHubNotConfiguredError(
883
+ "GitHub MCP not configured.\n"
884
+ "Add a 'github' entry to .canopy/mcps.json:\n"
885
+ " {\n"
886
+ ' "github": {\n'
887
+ ' "command": "npx",\n'
888
+ ' "args": ["-y", "@modelcontextprotocol/server-github"],\n'
889
+ ' "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."}\n'
890
+ " }\n"
891
+ " }"
892
+ )
893
+
894
+ lane = self.status(name)
895
+ results: dict[str, dict] = {}
896
+ has_any_pr = False
897
+
898
+ for repo_name in lane.repos:
899
+ try:
900
+ state = self.workspace.get_repo(repo_name)
901
+ except KeyError:
902
+ results[repo_name] = {"error": "repo not found"}
903
+ continue
904
+
905
+ remote = git.remote_url(state.abs_path)
906
+ if not remote:
907
+ results[repo_name] = {
908
+ "branch": name,
909
+ "error": "no remote URL configured",
910
+ }
911
+ continue
912
+
913
+ parsed = _extract_owner_repo(remote)
914
+ if not parsed:
915
+ results[repo_name] = {
916
+ "branch": name,
917
+ "error": f"could not parse GitHub owner/repo from: {remote}",
918
+ }
919
+ continue
920
+
921
+ owner, repo_slug = parsed
922
+ try:
923
+ pr = find_pull_request(
924
+ self.workspace.config.root, owner, repo_slug, name,
925
+ )
926
+ if pr:
927
+ has_any_pr = True
928
+ results[repo_name] = {
929
+ "branch": name,
930
+ "owner": owner,
931
+ "repo_name": repo_slug,
932
+ "pr": pr,
933
+ }
934
+ except Exception as e:
935
+ results[repo_name] = {
936
+ "branch": name,
937
+ "owner": owner,
938
+ "repo_name": repo_slug,
939
+ "pr": None,
940
+ "error": str(e),
941
+ }
942
+
943
+ return {
944
+ "feature": name,
945
+ "has_prs": has_any_pr,
946
+ "repos": results,
947
+ }
948
+
949
+ def review_comments(self, name: str) -> dict:
950
+ """Fetch PR review comments classified by temporal staleness.
951
+
952
+ Precondition: at least one repo in the lane must have a PR. If
953
+ none do, raises ``PullRequestNotFoundError``.
954
+
955
+ Per repo, threads are sorted into:
956
+ - ``actionable_threads``: full comment data; agent reads these
957
+ - ``likely_resolved_threads``: slim summary + addressing commit
958
+ - ``resolved_thread_count``: GitHub-flagged resolved (excluded)
959
+
960
+ See ``actions.review_filter.classify_threads`` for the algorithm
961
+ (validated against 4 real PRs in the research doc).
962
+
963
+ Returns:
964
+ {
965
+ "feature": str,
966
+ "actionable_count": int, # across all repos
967
+ "likely_resolved_count": int,
968
+ "resolved_thread_count": int,
969
+ "repos": {
970
+ "<repo>": {
971
+ "pr_number": int,
972
+ "pr_url": str,
973
+ "pr_title": str,
974
+ "latest_commit_at": str, # ISO 8601 of branch HEAD
975
+ "actionable_threads": [...],
976
+ "likely_resolved_threads": [...],
977
+ "resolved_thread_count": int,
978
+ }
979
+ }
980
+ }
981
+
982
+ Raises:
983
+ PullRequestNotFoundError: If no PR exists for any repo.
984
+ GitHubNotConfiguredError: If GitHub MCP is not configured.
985
+ """
986
+ from ..integrations.github import (
987
+ get_review_comments,
988
+ PullRequestNotFoundError,
989
+ GitHubNotConfiguredError,
990
+ )
991
+ from ..actions.review_filter import classify_threads
992
+
993
+ name = self._resolve_name(name)
994
+ status = self.review_status(name)
995
+ if not status["has_prs"]:
996
+ raise PullRequestNotFoundError(
997
+ f"No open PRs found for feature '{name}' in any repo. "
998
+ "Push your branch and create a PR first."
999
+ )
1000
+
1001
+ results: dict[str, dict] = {}
1002
+ actionable_total = 0
1003
+ likely_resolved_total = 0
1004
+ resolved_total = 0
1005
+
1006
+ for repo_name, info in status["repos"].items():
1007
+ pr = info.get("pr")
1008
+ if not pr:
1009
+ continue
1010
+
1011
+ owner = info.get("owner", "")
1012
+ repo_slug = info.get("repo_name", "")
1013
+ pr_number = pr["number"]
1014
+
1015
+ try:
1016
+ comments, resolved_count = get_review_comments(
1017
+ self.workspace.config.root, owner, repo_slug, pr_number,
1018
+ )
1019
+ repo_state = self.workspace.get_repo(repo_name)
1020
+ branch = info.get("branch") or repo_state.current_branch
1021
+ classification = classify_threads(
1022
+ comments, repo_state.abs_path, branch,
1023
+ )
1024
+ # Promote the GitHub-resolved count from upstream filtering.
1025
+ classification["resolved_thread_count"] = resolved_count
1026
+
1027
+ actionable_total += len(classification["actionable_threads"])
1028
+ likely_resolved_total += len(classification["likely_resolved_threads"])
1029
+ resolved_total += resolved_count
1030
+
1031
+ results[repo_name] = {
1032
+ "pr_number": pr_number,
1033
+ "pr_url": pr.get("url", ""),
1034
+ "pr_title": pr.get("title", ""),
1035
+ **classification,
1036
+ }
1037
+ except Exception as e:
1038
+ results[repo_name] = {
1039
+ "pr_number": pr_number,
1040
+ "pr_url": pr.get("url", ""),
1041
+ "pr_title": pr.get("title", ""),
1042
+ "actionable_threads": [],
1043
+ "likely_resolved_threads": [],
1044
+ "resolved_thread_count": 0,
1045
+ "latest_commit_at": "",
1046
+ "error": str(e),
1047
+ }
1048
+
1049
+ return {
1050
+ "feature": name,
1051
+ "actionable_count": actionable_total,
1052
+ "likely_resolved_count": likely_resolved_total,
1053
+ "resolved_thread_count": resolved_total,
1054
+ "repos": results,
1055
+ }
1056
+
1057
+ def review_prep(self, name: str, message: str = "") -> dict:
1058
+ """Run pre-commit hooks and stage changes for a feature lane.
1059
+
1060
+ This is the "get to commit-ready state" workflow:
1061
+ 1. Resolve feature → repo paths (worktree or checked-out)
1062
+ 2. Run pre-commit hooks in each repo
1063
+ 3. Stage all changes (git add -A)
1064
+ 4. Report results (does NOT commit — leaves that to the caller)
1065
+
1066
+ If message is provided, it's included in the result for the caller
1067
+ to use as a commit message.
1068
+
1069
+ Returns:
1070
+ {
1071
+ "feature": str,
1072
+ "message": str,
1073
+ "repos": {
1074
+ "<repo>": {
1075
+ "path": str,
1076
+ "precommit": {type, passed, output},
1077
+ "staged": bool,
1078
+ "dirty_count": int,
1079
+ "error": str (optional),
1080
+ }
1081
+ },
1082
+ "all_passed": bool,
1083
+ }
1084
+ """
1085
+ from ..integrations.precommit import run_precommit
1086
+ from ..actions.augments import repo_augments
1087
+
1088
+ name = self._resolve_name(name)
1089
+ paths = self.resolve_paths(name)
1090
+ if not paths:
1091
+ raise ValueError(f"No working directories found for feature '{name}'")
1092
+
1093
+ results: dict[str, dict] = {}
1094
+ all_passed = True
1095
+
1096
+ for repo_name, path_str in paths.items():
1097
+ repo_path = Path(path_str)
1098
+ entry: dict = {"path": path_str}
1099
+
1100
+ # Run pre-commit hooks (honoring per-repo augments.preflight_cmd)
1101
+ try:
1102
+ augments = repo_augments(self.workspace.config, repo_name)
1103
+ pc_result = run_precommit(repo_path, augments=augments)
1104
+ entry["precommit"] = pc_result
1105
+ if not pc_result["passed"]:
1106
+ all_passed = False
1107
+ except Exception as e:
1108
+ entry["precommit"] = {
1109
+ "type": "error",
1110
+ "passed": False,
1111
+ "output": str(e),
1112
+ }
1113
+ all_passed = False
1114
+
1115
+ # Stage all changes
1116
+ try:
1117
+ porcelain = git.status_porcelain(repo_path)
1118
+ if porcelain:
1119
+ git._run(["add", "-A"], cwd=repo_path)
1120
+ entry["staged"] = True
1121
+ entry["dirty_count"] = len(porcelain)
1122
+ else:
1123
+ entry["staged"] = False
1124
+ entry["dirty_count"] = 0
1125
+ except git.GitError as e:
1126
+ entry["staged"] = False
1127
+ entry["dirty_count"] = 0
1128
+ entry["error"] = str(e)
1129
+
1130
+ results[repo_name] = entry
1131
+
1132
+ # Persist the result so feature_state can distinguish IN_PROGRESS
1133
+ # from READY_TO_COMMIT. Records HEAD sha per repo at the time the
1134
+ # preflight ran; freshness is decided by comparing those shas
1135
+ # against current HEADs.
1136
+ try:
1137
+ from ..actions.preflight_state import record_result
1138
+ head_sha_per_repo: dict[str, str] = {}
1139
+ for repo_name in paths.keys():
1140
+ try:
1141
+ repo_state = self.workspace.get_repo(repo_name)
1142
+ head_sha_per_repo[repo_name] = git.head_sha(repo_state.abs_path)
1143
+ except Exception:
1144
+ pass
1145
+ record_result(
1146
+ self.workspace.config.root, name,
1147
+ passed=all_passed,
1148
+ head_sha_per_repo=head_sha_per_repo,
1149
+ summary=("all checks passed" if all_passed
1150
+ else "one or more checks failed"),
1151
+ )
1152
+ except Exception:
1153
+ # State tracking is auxiliary; don't fail review_prep itself.
1154
+ pass
1155
+
1156
+ return {
1157
+ "feature": name,
1158
+ "message": message,
1159
+ "repos": results,
1160
+ "all_passed": all_passed,
1161
+ }
1162
+
1163
+ def _count_active_worktrees(self) -> int:
1164
+ """Count occupied slots from slots.json."""
1165
+ state = slots_mod.read_state(self.workspace)
1166
+ if state is None:
1167
+ return 0
1168
+ return len(state.slots)
1169
+
1170
+ def _find_stale_worktrees(self) -> list[dict]:
1171
+ """Find slot-occupied features that are candidates for cleanup.
1172
+
1173
+ Wave 3.0: iterates the ``slots`` map in slots.json (not
1174
+ feature-named directories). A slot is 'stale' if its feature is:
1175
+ - Marked as done/merged/abandoned in features.json, OR
1176
+ - All its repos are clean (no dirty files) and the branch has
1177
+ been merged into default.
1178
+
1179
+ Returns a list of {name, slot_id, reason} dicts, most stale first.
1180
+ """
1181
+ slot_state = slots_mod.read_state(self.workspace)
1182
+ if slot_state is None or not slot_state.slots:
1183
+ return []
1184
+
1185
+ features = self._load_features()
1186
+ stale = []
1187
+
1188
+ for slot_id, entry in sorted(slot_state.slots.items()):
1189
+ feat_name = entry.feature
1190
+ meta = features.get(feat_name, {})
1191
+ slot_dir = (
1192
+ self.workspace.config.root / _WORKTREE_DIR / slot_id
1193
+ )
1194
+
1195
+ # Check if archived
1196
+ status = meta.get("status", "active")
1197
+ if status in ("done", "merged", "abandoned"):
1198
+ stale.append({
1199
+ "name": feat_name, "slot_id": slot_id,
1200
+ "reason": f"status: {status}",
1201
+ })
1202
+ continue
1203
+
1204
+ if not slot_dir.is_dir():
1205
+ continue
1206
+
1207
+ # Check if all repos are clean and merged
1208
+ all_clean = True
1209
+ all_merged = True
1210
+ for repo_dir in slot_dir.iterdir():
1211
+ if not repo_dir.is_dir():
1212
+ continue
1213
+ try:
1214
+ porcelain = git.status_porcelain(repo_dir)
1215
+ if porcelain:
1216
+ all_clean = False
1217
+ except git.GitError:
1218
+ pass
1219
+
1220
+ try:
1221
+ repo_name = repo_dir.name
1222
+ state = self.workspace.get_repo(repo_name)
1223
+ ahead, _ = git.divergence(
1224
+ repo_dir, feat_name, state.config.default_branch,
1225
+ )
1226
+ if ahead > 0:
1227
+ all_merged = False
1228
+ except (KeyError, git.GitError):
1229
+ pass
1230
+
1231
+ if all_clean and all_merged:
1232
+ stale.append({
1233
+ "name": feat_name, "slot_id": slot_id,
1234
+ "reason": "clean and merged",
1235
+ })
1236
+ elif all_clean:
1237
+ stale.append({
1238
+ "name": feat_name, "slot_id": slot_id,
1239
+ "reason": "clean (not yet merged)",
1240
+ })
1241
+
1242
+ return stale
1243
+
1244
+ def _load_features(self) -> dict:
1245
+ """Load features.json, returning empty dict if not found."""
1246
+ if not self._store_path.exists():
1247
+ return {}
1248
+ try:
1249
+ return json.loads(self._store_path.read_text())
1250
+ except (json.JSONDecodeError, OSError):
1251
+ return {}
1252
+
1253
+ def _save_features(self, features: dict) -> None:
1254
+ """Save features.json."""
1255
+ self._store_path.parent.mkdir(parents=True, exist_ok=True)
1256
+ self._store_path.write_text(json.dumps(features, indent=2))