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,221 @@
1
+ """Slot state — single source of truth for canopy's canonical + warm features.
2
+
3
+ State file: .canopy/state/slots.json (atomic temp+rename writes).
4
+
5
+ Schema::
6
+
7
+ {
8
+ "version": 1,
9
+ "slot_count": 2,
10
+ "canonical": {feature, activated_at, per_repo_paths},
11
+ "previous_canonical": str | null,
12
+ "slots": {"worktree-1": {feature, occupied_at}, ...},
13
+ "last_touched": {feature: iso, ...},
14
+ "in_flight": {feature_being_promoted, previously_canonical,
15
+ started_at, per_repo_completed, failed_repo,
16
+ error_what} | null
17
+ }
18
+
19
+ Validation on read: a missing canonical path clears ``canonical`` only —
20
+ the slots/last_touched maps are independent of the canonical pointer and
21
+ must NOT be discarded when the canonical entry is stale. Catastrophic
22
+ cases (file missing, JSON unparseable, top-level not a dict) still
23
+ return None.
24
+
25
+ Missing slot dirs → silently drop from the returned state.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from ..workspace.workspace import Workspace
36
+
37
+
38
+ SLOTS_DIR = ".canopy/worktrees"
39
+
40
+
41
+ @dataclass
42
+ class CanonicalEntry:
43
+ feature: str
44
+ activated_at: str
45
+ per_repo_paths: dict[str, str]
46
+
47
+
48
+ @dataclass
49
+ class SlotEntry:
50
+ feature: str
51
+ occupied_at: str
52
+
53
+
54
+ @dataclass
55
+ class SlotState:
56
+ slot_count: int = 2
57
+ canonical: CanonicalEntry | None = None
58
+ previous_canonical: str | None = None
59
+ slots: dict[str, SlotEntry] = field(default_factory=dict)
60
+ last_touched: dict[str, str] = field(default_factory=dict)
61
+ in_flight: dict | None = None
62
+
63
+ def to_dict(self) -> dict:
64
+ d: dict[str, Any] = {
65
+ "version": 1,
66
+ "slot_count": self.slot_count,
67
+ "previous_canonical": self.previous_canonical,
68
+ "slots": {
69
+ sid: {"feature": e.feature, "occupied_at": e.occupied_at}
70
+ for sid, e in self.slots.items()
71
+ },
72
+ "last_touched": dict(self.last_touched),
73
+ "in_flight": dict(self.in_flight) if self.in_flight else None,
74
+ }
75
+ if self.canonical is not None:
76
+ d["canonical"] = {
77
+ "feature": self.canonical.feature,
78
+ "activated_at": self.canonical.activated_at,
79
+ "per_repo_paths": dict(self.canonical.per_repo_paths),
80
+ }
81
+ else:
82
+ d["canonical"] = None
83
+ return d
84
+
85
+
86
+ def _state_path(workspace: Workspace) -> Path:
87
+ return workspace.config.root / ".canopy" / "state" / "slots.json"
88
+
89
+
90
+ def _slots_root(workspace: Workspace) -> Path:
91
+ return workspace.config.root / SLOTS_DIR
92
+
93
+
94
+ def now_iso() -> str:
95
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
96
+
97
+
98
+ def read_state(workspace: Workspace) -> SlotState | None:
99
+ path = _state_path(workspace)
100
+ if not path.exists():
101
+ return None
102
+ try:
103
+ data = json.loads(path.read_text())
104
+ except (OSError, json.JSONDecodeError):
105
+ return None
106
+ if not isinstance(data, dict):
107
+ return None
108
+
109
+ # Canonical staleness check — stale canonical is NOT fatal to the
110
+ # rest of the state. Slots and last_touched are independent of the
111
+ # canonical pointer; clear canonical only and preserve the rest.
112
+ canonical_raw = data.get("canonical")
113
+ canonical: CanonicalEntry | None = None
114
+ if isinstance(canonical_raw, dict) and canonical_raw.get("feature"):
115
+ per_repo = canonical_raw.get("per_repo_paths") or {}
116
+ if not isinstance(per_repo, dict):
117
+ # Malformed canonical block — treat as no canonical, keep rest.
118
+ canonical = None
119
+ else:
120
+ stale = any(not Path(p).exists() for p in per_repo.values())
121
+ if stale:
122
+ canonical = None
123
+ else:
124
+ canonical = CanonicalEntry(
125
+ feature=canonical_raw["feature"],
126
+ activated_at=canonical_raw.get("activated_at", ""),
127
+ per_repo_paths=dict(per_repo),
128
+ )
129
+
130
+ slots_raw = data.get("slots") or {}
131
+ slots_root = _slots_root(workspace)
132
+ slots_out: dict[str, SlotEntry] = {}
133
+ for sid, entry in slots_raw.items():
134
+ if not isinstance(entry, dict):
135
+ continue
136
+ # Drop slots whose dir is gone (stale on filesystem)
137
+ if not (slots_root / sid).exists():
138
+ continue
139
+ slots_out[sid] = SlotEntry(
140
+ feature=entry.get("feature", ""),
141
+ occupied_at=entry.get("occupied_at", ""),
142
+ )
143
+
144
+ in_flight_raw = data.get("in_flight")
145
+ in_flight = (
146
+ dict(in_flight_raw) if isinstance(in_flight_raw, dict) else None
147
+ )
148
+
149
+ return SlotState(
150
+ slot_count=int(data.get("slot_count", 2)),
151
+ canonical=canonical,
152
+ previous_canonical=data.get("previous_canonical"),
153
+ slots=slots_out,
154
+ last_touched={
155
+ str(k): str(v) for k, v in (data.get("last_touched") or {}).items()
156
+ },
157
+ in_flight=in_flight,
158
+ )
159
+
160
+
161
+ def write_state(workspace: Workspace, state: SlotState) -> None:
162
+ path = _state_path(workspace)
163
+ path.parent.mkdir(parents=True, exist_ok=True)
164
+ tmp = path.with_suffix(".json.tmp")
165
+ tmp.write_text(json.dumps(state.to_dict(), indent=2))
166
+ tmp.replace(path)
167
+
168
+
169
+ def slot_worktree_path(workspace: Workspace, slot_id: str, repo: str) -> Path:
170
+ """Filesystem location of a slot's repo subdir."""
171
+ return _slots_root(workspace) / slot_id / repo
172
+
173
+
174
+ def slot_for_feature(workspace: Workspace, feature: str) -> str | None:
175
+ """Return the slot id currently holding ``feature``, or None."""
176
+ state = read_state(workspace)
177
+ if state is None:
178
+ return None
179
+ for sid, entry in state.slots.items():
180
+ if entry.feature == feature:
181
+ return sid
182
+ return None
183
+
184
+
185
+ def feature_for_slot(workspace: Workspace, slot_id: str) -> str | None:
186
+ """Return the feature currently in ``slot_id``, or None."""
187
+ state = read_state(workspace)
188
+ if state is None or slot_id not in state.slots:
189
+ return None
190
+ return state.slots[slot_id].feature
191
+
192
+
193
+ def allocate_slot(state: SlotState) -> str | None:
194
+ """Return the lowest-index free slot id, or None if all are full."""
195
+ occupied = set(state.slots.keys())
196
+ for i in range(1, state.slot_count + 1):
197
+ sid = f"worktree-{i}"
198
+ if sid not in occupied:
199
+ return sid
200
+ return None
201
+
202
+
203
+ def lru_evictee(
204
+ state: SlotState, *, exclude: set[str] | None = None,
205
+ ) -> str | None:
206
+ """Pick the LRU-coldest occupant feature from the warm slots.
207
+
208
+ Returns None when no eligible candidate. Sorting is deterministic:
209
+ (last_touched ASC, feature name ASC) — features with no timestamp
210
+ sort as oldest.
211
+ """
212
+ exclude = exclude or set()
213
+ candidates = [
214
+ e.feature for e in state.slots.values() if e.feature not in exclude
215
+ ]
216
+ if not candidates:
217
+ return None
218
+ return sorted(
219
+ candidates,
220
+ key=lambda f: (state.last_touched.get(f, ""), f),
221
+ )[0]
@@ -0,0 +1,230 @@
1
+ """Feature-aware stash tagging.
2
+
3
+ Wraps the existing per-repo stash primitives so a stash saved in a
4
+ feature context carries a structured prefix in its message. The prefix
5
+ shape — ``[canopy <feature> @ <iso_ts>] <user_message>`` — is readable
6
+ in ``git stash list`` output and easy to parse back.
7
+
8
+ Untagged stashes still work end-to-end. ``list_grouped`` groups stashes
9
+ by feature using the tag; entries without the prefix go in
10
+ ``untagged``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from typing import Any
18
+
19
+ from ..git import multi as git_multi
20
+ from ..git import repo as git
21
+ from ..workspace.workspace import Workspace
22
+ from .aliases import resolve_feature
23
+ from .errors import BlockerError, FailedError, FixAction
24
+
25
+
26
+ # Git stash auto-prefixes the stored message with "On <branch>: " (or
27
+ # "WIP on <branch>: ..." when no -m given). Match the canopy tag anywhere
28
+ # after that optional prefix; user message is whatever follows the ``]``.
29
+ _TAG = re.compile(r"\[canopy (\S+) @ (\S+)\] ?(.*)$", re.DOTALL)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class StashEntry:
34
+ repo: str
35
+ index: int
36
+ ref: str # 'stash@{N}'
37
+ message: str # raw message as git stored it
38
+ feature: str | None # parsed from tag, None if untagged
39
+ ts: str | None # parsed from tag
40
+ user_message: str # message text after the tag, OR raw if untagged
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "repo": self.repo,
45
+ "index": self.index,
46
+ "ref": self.ref,
47
+ "message": self.message,
48
+ "feature": self.feature,
49
+ "ts": self.ts,
50
+ "user_message": self.user_message,
51
+ }
52
+
53
+
54
+ def _format_tag(feature: str, message: str) -> str:
55
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
56
+ msg = message.strip()
57
+ return f"[canopy {feature} @ {ts}]" + (f" {msg}" if msg else "")
58
+
59
+
60
+ def parse_message(raw: str) -> tuple[str | None, str | None, str]:
61
+ """Extract ``(feature, ts, user_message)`` from a stored stash message.
62
+
63
+ Returns ``(None, None, raw)`` if the message isn't a canopy tag.
64
+ """
65
+ m = _TAG.search(raw)
66
+ if not m:
67
+ return None, None, raw
68
+ return m.group(1), m.group(2), m.group(3)
69
+
70
+
71
+ def _parse_entry(repo: str, raw: dict) -> StashEntry:
72
+ feature, ts, user_message = parse_message(raw.get("message", ""))
73
+ return StashEntry(
74
+ repo=repo,
75
+ index=raw.get("index", 0),
76
+ ref=raw.get("ref", f"stash@{{{raw.get('index', 0)}}}"),
77
+ message=raw.get("message", ""),
78
+ feature=feature,
79
+ ts=ts,
80
+ user_message=user_message,
81
+ )
82
+
83
+
84
+ def save_for_feature(
85
+ workspace: Workspace,
86
+ feature: str,
87
+ message: str,
88
+ repos: list[str] | None = None,
89
+ ) -> dict[str, Any]:
90
+ """Stash dirty changes in feature.repos with a feature-tagged message.
91
+
92
+ ``repos`` overrides which repos to stash (default: all repos in the
93
+ feature lane). Repos not in the feature lane are silently skipped
94
+ when expanding from the lane; if ``repos`` is passed explicitly,
95
+ unknown names raise.
96
+ """
97
+ feature_name = resolve_feature(workspace, feature)
98
+ target_repos = _select_repos(workspace, feature_name, repos)
99
+ tagged_message = _format_tag(feature_name, message)
100
+
101
+ raw = git_multi.stash_save_all(
102
+ workspace, tagged_message, target_repos, include_untracked=True,
103
+ )
104
+ return {
105
+ "feature": feature_name,
106
+ "message": tagged_message,
107
+ "repos": raw, # {repo: "stashed" | "clean" | error_str}
108
+ }
109
+
110
+
111
+ def list_grouped(
112
+ workspace: Workspace, feature: str | None = None,
113
+ ) -> dict[str, Any]:
114
+ """List stashes across repos, grouped by feature tag.
115
+
116
+ If ``feature`` is passed, returns only entries matching that feature
117
+ (in ``by_feature[<feature>]``). Otherwise groups all tagged entries
118
+ by feature; unmatched go to ``untagged``.
119
+ """
120
+ target_feature = resolve_feature(workspace, feature) if feature else None
121
+ raw_per_repo = git_multi.stash_list_all(workspace)
122
+
123
+ by_feature: dict[str, list[dict]] = {}
124
+ untagged: list[dict] = []
125
+
126
+ for repo_name, entries in raw_per_repo.items():
127
+ for raw in entries:
128
+ entry = _parse_entry(repo_name, raw)
129
+ if entry.feature is None:
130
+ if target_feature is not None:
131
+ continue # feature filter applied — skip untagged
132
+ untagged.append(entry.to_dict())
133
+ continue
134
+ if target_feature is not None and entry.feature != target_feature:
135
+ continue
136
+ by_feature.setdefault(entry.feature, []).append(entry.to_dict())
137
+
138
+ return {"by_feature": by_feature, "untagged": untagged}
139
+
140
+
141
+ def pop_feature(
142
+ workspace: Workspace,
143
+ feature: str,
144
+ repos: list[str] | None = None,
145
+ ) -> dict[str, Any]:
146
+ """Pop the most recent tagged stash for a feature in each target repo.
147
+
148
+ Returns per-repo ``{status: 'popped' | 'no_match' | 'failed', ref, message}``.
149
+ Raises ``BlockerError`` if no matching stash exists in any target repo.
150
+ """
151
+ feature_name = resolve_feature(workspace, feature)
152
+ target_repos = _select_repos(workspace, feature_name, repos)
153
+
154
+ raw_per_repo = git_multi.stash_list_all(workspace)
155
+ results: dict[str, dict] = {}
156
+ any_popped = False
157
+
158
+ for repo_name in target_repos:
159
+ entries_raw = raw_per_repo.get(repo_name, [])
160
+ # Most recent matching = lowest index where feature matches.
161
+ match: StashEntry | None = None
162
+ for raw in entries_raw:
163
+ parsed = _parse_entry(repo_name, raw)
164
+ if parsed.feature == feature_name:
165
+ if match is None or parsed.index < match.index:
166
+ match = parsed
167
+ if match is None:
168
+ results[repo_name] = {"status": "no_match"}
169
+ continue
170
+ try:
171
+ state = workspace.get_repo(repo_name)
172
+ git.stash_pop(state.abs_path, match.index)
173
+ results[repo_name] = {
174
+ "status": "popped",
175
+ "ref": match.ref,
176
+ "message": match.user_message,
177
+ "ts": match.ts,
178
+ }
179
+ any_popped = True
180
+ except git.GitError as e:
181
+ results[repo_name] = {
182
+ "status": "failed",
183
+ "ref": match.ref,
184
+ "error": str(e),
185
+ }
186
+
187
+ if not any_popped:
188
+ raise BlockerError(
189
+ code="no_tagged_stash",
190
+ what=f"no stash tagged with feature '{feature_name}' in any target repo",
191
+ expected={"feature": feature_name, "target_repos": list(target_repos)},
192
+ actual={"per_repo": results},
193
+ fix_actions=[
194
+ FixAction(action="stash list", args={"feature": feature_name},
195
+ safe=True, preview="see what tagged stashes exist"),
196
+ ],
197
+ )
198
+
199
+ return {"feature": feature_name, "repos": results}
200
+
201
+
202
+ def _select_repos(
203
+ workspace: Workspace,
204
+ feature_name: str,
205
+ requested: list[str] | None,
206
+ ) -> list[str]:
207
+ """Pick the repo set for a stash op.
208
+
209
+ If ``requested`` is given, validate names. Otherwise default to the
210
+ feature lane's ``repos`` (from features.json) or all workspace repos
211
+ when the feature is implicit / has no explicit lane.
212
+ """
213
+ all_names = {r.config.name for r in workspace.repos}
214
+
215
+ if requested is not None:
216
+ unknown = [r for r in requested if r not in all_names]
217
+ if unknown:
218
+ raise BlockerError(
219
+ code="unknown_repo",
220
+ what=f"unknown repos: {', '.join(unknown)}",
221
+ expected={"available_repos": sorted(all_names)},
222
+ details={"requested": list(requested)},
223
+ )
224
+ return list(requested)
225
+
226
+ from ..features.coordinator import FeatureCoordinator
227
+ features = FeatureCoordinator(workspace)._load_features()
228
+ feature_data = features.get(feature_name) or {}
229
+ declared = feature_data.get("repos") or sorted(all_names)
230
+ return [r for r in declared if r in all_names]