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,192 @@
1
+ """Preflight for ``switch`` — predictable-failure detection without state mutation.
2
+
3
+ Catches the classes of failure that are knowable from the current
4
+ filesystem + git state alone:
5
+
6
+ - target branch missing in any repo (and not creatable from default)
7
+ - leftover warm-worktree directory from a previous failed run
8
+ - git index lock currently held in any participating repo
9
+ - cap reached + no fix path acceptable (active-rotation past warm cap)
10
+
11
+ Returns ``None`` when everything checks out; raises a structured
12
+ ``BlockerError`` with all detected issues otherwise. Bundling per-repo
13
+ failures into one error means the user sees the full picture in one
14
+ shot instead of fixing one issue at a time.
15
+
16
+ Defense-in-depth: preflight catches ~80% of failures cheaply. The rest
17
+ (disk fills mid-op, network blip during fetch, IDE racing the checkout)
18
+ need the rollback walker — that's PR2.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ..git import repo as git
26
+ from ..workspace.workspace import Workspace
27
+ from . import slots as slots_mod
28
+ from .errors import BlockerError, FixAction
29
+
30
+
31
+ # Default cap when slots is unset. The config parser already defaults
32
+ # `slots` to 2, so this is purely defensive.
33
+ DEFAULT_WARM_SLOT_CAP = 2
34
+
35
+
36
+ def warm_slot_cap(workspace: Workspace) -> int:
37
+ """Return the warm-slot cap honored by switch's canonical-slot logic."""
38
+ raw = workspace.config.slots
39
+ return raw if raw and raw > 0 else DEFAULT_WARM_SLOT_CAP
40
+
41
+
42
+ def preflight(
43
+ workspace: Workspace,
44
+ feature_to_activate: str,
45
+ repo_branches: dict[str, str],
46
+ *,
47
+ release_current: bool = False,
48
+ no_evict: bool = False,
49
+ evict_to: str | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Pre-validate a ``switch`` call. Raises ``BlockerError`` on failure.
52
+
53
+ Args:
54
+ feature_to_activate: the feature being promoted to canonical (Y).
55
+ repo_branches: per-repo branch map for Y, from ``repos_for_feature``.
56
+ release_current: if True (wind-down mode), the cap-reached check
57
+ is skipped (X goes cold, no warm slot consumed).
58
+ no_evict: in active-rotation mode, refuse to evict an LRU warm
59
+ worktree when the cap is full instead of asking the user.
60
+ evict_to: if set, the user has pinned a destination slot for X.
61
+ Skip the cap-fire check — the explicit-slot path (in switch)
62
+ handles validation + eviction of any occupant.
63
+
64
+ Returns a small fact dict the caller can use to make decisions:
65
+ ``{branches_to_create: [(repo, branch)], cap_will_fire: bool,
66
+ lru_eviction_candidate: <feature> | None,
67
+ previously_canonical: <feature> | None}``.
68
+ """
69
+ # Per-repo branch + path checks
70
+ branches_to_create: list[tuple[str, str]] = []
71
+ issues: list[dict[str, Any]] = []
72
+
73
+ for repo_name, branch in repo_branches.items():
74
+ try:
75
+ state = workspace.get_repo(repo_name)
76
+ except KeyError:
77
+ issues.append({
78
+ "repo": repo_name,
79
+ "kind": "repo_not_in_workspace",
80
+ "what": f"repo '{repo_name}' not in canopy.toml",
81
+ })
82
+ continue
83
+ repo_path = state.abs_path
84
+
85
+ # Lock check — git refuses to operate while index.lock exists
86
+ if (repo_path / ".git" / "index.lock").exists():
87
+ issues.append({
88
+ "repo": repo_name,
89
+ "kind": "index_lock_held",
90
+ "what": (
91
+ f".git/index.lock present in {repo_name} — another git"
92
+ " process may be running"
93
+ ),
94
+ })
95
+
96
+ # Branch existence — we'll create from default if missing
97
+ if not git.branch_exists(repo_path, branch):
98
+ branches_to_create.append((repo_name, branch))
99
+
100
+ # Read the slot state (3.0 layout). previously_canonical is the
101
+ # canonical feature, if any, that differs from Y.
102
+ state = slots_mod.read_state(workspace)
103
+ previously_canonical: str | None = None
104
+ if state and state.canonical and state.canonical.feature != feature_to_activate:
105
+ previously_canonical = state.canonical.feature
106
+
107
+ already_warm: set[str] = (
108
+ {e.feature for e in state.slots.values()} if state else set()
109
+ )
110
+
111
+ # Cap-will-fire check (only active-rotation mode evacuates X to warm).
112
+ # When the user pinned a destination via ``--evict-to``, the
113
+ # explicit-slot path in switch handles validation + occupant
114
+ # eviction, so skip the cap-fire surface here.
115
+ cap_will_fire = False
116
+ lru_eviction_candidate: str | None = None
117
+ if previously_canonical and not release_current and evict_to is None:
118
+ cap = warm_slot_cap(workspace)
119
+ # Y is becoming canonical, so if Y was warm it leaves the warm set;
120
+ # X (previously_canonical) is joining the warm set.
121
+ post_switch_warm = (already_warm - {feature_to_activate}) | {previously_canonical}
122
+ if len(post_switch_warm) > cap:
123
+ cap_will_fire = True
124
+ lru_eviction_candidate = slots_mod.lru_evictee(
125
+ state, exclude={feature_to_activate},
126
+ )
127
+ if no_evict or lru_eviction_candidate is None:
128
+ issues.append({
129
+ "kind": "worktree_cap_reached",
130
+ "what": (
131
+ f"adding {previously_canonical} as warm would exceed"
132
+ f" warm_slot_cap={cap} (currently warm:"
133
+ f" {sorted(already_warm)})"
134
+ ),
135
+ "current_warm": sorted(already_warm),
136
+ "cap": cap,
137
+ })
138
+
139
+ if issues:
140
+ cap_issue = next((i for i in issues if i.get("kind") == "worktree_cap_reached"), None)
141
+ if cap_issue:
142
+ raise BlockerError(
143
+ code="worktree_cap_reached",
144
+ what=cap_issue["what"],
145
+ expected={"warm_slot_cap": cap_issue["cap"]},
146
+ actual={"warm_now": cap_issue["current_warm"]},
147
+ fix_actions=[
148
+ FixAction(
149
+ action="switch",
150
+ args={"feature": feature_to_activate, "release_current": True},
151
+ safe=False,
152
+ preview=(
153
+ f"wind-down mode: {previously_canonical} goes"
154
+ f" cold (with stash), no eviction needed"
155
+ ),
156
+ ),
157
+ FixAction(
158
+ action="switch",
159
+ args={
160
+ "feature": feature_to_activate,
161
+ "evict": lru_eviction_candidate,
162
+ } if lru_eviction_candidate else {"feature": feature_to_activate},
163
+ safe=False,
164
+ preview=(
165
+ f"evict LRU warm worktree"
166
+ f" '{lru_eviction_candidate}' to cold"
167
+ if lru_eviction_candidate
168
+ else "no LRU candidate found — set last_touched manually"
169
+ ),
170
+ ),
171
+ FixAction(
172
+ action="workspace_config",
173
+ args={"slots": cap_issue["cap"] + 1},
174
+ safe=True,
175
+ preview=f"raise warm_slot_cap to {cap_issue['cap'] + 1}",
176
+ ),
177
+ ],
178
+ details={"all_issues": issues},
179
+ )
180
+ # Non-cap blockers
181
+ raise BlockerError(
182
+ code="switch_preflight_failed",
183
+ what=f"{len(issues)} issue(s) detected before switch could proceed",
184
+ details={"issues": issues},
185
+ )
186
+
187
+ return {
188
+ "branches_to_create": branches_to_create,
189
+ "cap_will_fire": cap_will_fire,
190
+ "lru_eviction_candidate": lru_eviction_candidate,
191
+ "previously_canonical": previously_canonical,
192
+ }
@@ -0,0 +1,88 @@
1
+ """Thread action wrappers — resolve, reply, unresolve a GitHub review thread.
2
+
3
+ Each wrapper calls the GitHub integration and records the event locally in
4
+ ``.canopy/state/thread_resolutions.json`` so the resume brief can attribute
5
+ "resolved by canopy" vs "resolved on GitHub directly".
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from ..workspace.workspace import Workspace
10
+ from .errors import ActionError, BlockerError
11
+ from . import thread_resolutions as tr
12
+
13
+
14
+ def _validate_thread_id(thread_id: str) -> None:
15
+ if not thread_id.startswith("PRRT_"):
16
+ raise BlockerError(
17
+ code="invalid_thread_id",
18
+ what=f"thread_id must start with 'PRRT_'; got {thread_id!r}",
19
+ )
20
+
21
+
22
+ def resolve_thread(
23
+ workspace: Workspace,
24
+ thread_id: str,
25
+ *,
26
+ feature: str,
27
+ via_command: str = "resolve",
28
+ via_commit_sha: str | None = None,
29
+ ) -> dict:
30
+ """Resolve a GitHub PR review thread and record it locally.
31
+
32
+ Steps:
33
+ 1. Validate thread_id format.
34
+ 2. Call the GitHub GraphQL mutation.
35
+ 3. Log the resolution to ``.canopy/state/thread_resolutions.json``.
36
+ 4. Return the combined result.
37
+
38
+ Raises:
39
+ BlockerError: if ``thread_id`` does not start with ``PRRT_``.
40
+ """
41
+ from ..integrations import github as gh
42
+
43
+ _validate_thread_id(thread_id)
44
+ gh_result = gh.resolve_thread(workspace.config.root, thread_id)
45
+ log_entry = tr.record(
46
+ workspace.config.root,
47
+ thread_id=thread_id,
48
+ feature=feature,
49
+ via_command=via_command,
50
+ via_commit_sha=via_commit_sha,
51
+ )
52
+ return {**gh_result, "logged": log_entry}
53
+
54
+
55
+ def reply_to_thread(
56
+ workspace: Workspace,
57
+ thread_id: str,
58
+ body: str,
59
+ *,
60
+ feature: str,
61
+ resolve_after: bool = False,
62
+ ) -> dict:
63
+ """Post a reply to a GitHub PR review thread, optionally resolving it.
64
+
65
+ Steps:
66
+ 1. Validate thread_id format.
67
+ 2. Post the reply via the GitHub GraphQL mutation.
68
+ 3. If ``resolve_after`` is True, resolve the thread and include the result.
69
+
70
+ Returns a dict with ``posted`` and optionally ``resolved`` keys.
71
+
72
+ Raises:
73
+ BlockerError: if ``thread_id`` does not start with ``PRRT_``.
74
+ """
75
+ from ..integrations import github as gh
76
+
77
+ _validate_thread_id(thread_id)
78
+ posted = gh.reply_to_thread(workspace.config.root, thread_id, body)
79
+ result: dict = {"posted": posted}
80
+ if resolve_after:
81
+ try:
82
+ res = resolve_thread(
83
+ workspace, thread_id, feature=feature, via_command="reply_resolve",
84
+ )
85
+ result["resolved"] = res
86
+ except ActionError as e:
87
+ result["resolved"] = {"error": e.to_dict()}
88
+ return result
@@ -0,0 +1,101 @@
1
+ """Thread resolution log — persistent record of threads resolved via canopy.
2
+
3
+ State file: .canopy/state/thread_resolutions.json (atomic temp+rename writes).
4
+
5
+ Schema::
6
+
7
+ {
8
+ "PRRT_abc": {
9
+ "resolved_by_canopy_at": "2026-05-29T12:00:00Z",
10
+ "feature": "auth-flow",
11
+ "via_command": "resolve" | "commit_address" | "reply_resolve",
12
+ "via_commit_sha": "abc1234" | null
13
+ }
14
+ }
15
+
16
+ Used by the resume brief to attribute "resolved by canopy" vs "resolved on
17
+ GitHub directly" — only canopy-initiated resolutions appear here.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import tempfile
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+
28
+ def _state_path(workspace_root: Path) -> Path:
29
+ return workspace_root / ".canopy" / "state" / "thread_resolutions.json"
30
+
31
+
32
+ def _now_iso() -> str:
33
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
34
+
35
+
36
+ def load(workspace_root: Path) -> dict:
37
+ """Return the full log dict. Missing file returns an empty dict."""
38
+ path = _state_path(workspace_root)
39
+ if not path.exists():
40
+ return {}
41
+ try:
42
+ data = json.loads(path.read_text())
43
+ if isinstance(data, dict):
44
+ return data
45
+ except (OSError, json.JSONDecodeError):
46
+ pass
47
+ return {}
48
+
49
+
50
+ def record(
51
+ workspace_root: Path,
52
+ *,
53
+ thread_id: str,
54
+ feature: str,
55
+ via_command: str,
56
+ via_commit_sha: str | None = None,
57
+ ) -> dict:
58
+ """Append or overwrite a thread resolution entry. Returns the entry written.
59
+
60
+ Atomic write: mkstemp in same dir → write → os.replace.
61
+ """
62
+ log = load(workspace_root)
63
+ entry = {
64
+ "resolved_by_canopy_at": _now_iso(),
65
+ "feature": feature,
66
+ "via_command": via_command,
67
+ "via_commit_sha": via_commit_sha,
68
+ }
69
+ log[thread_id] = entry
70
+
71
+ path = _state_path(workspace_root)
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+
74
+ fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".json.tmp")
75
+ try:
76
+ with os.fdopen(fd, "w") as fh:
77
+ json.dump(log, fh, indent=2)
78
+ os.replace(tmp_path, path)
79
+ except Exception:
80
+ try:
81
+ os.unlink(tmp_path)
82
+ except OSError:
83
+ pass
84
+ raise
85
+
86
+ return entry
87
+
88
+
89
+ def filter_since(workspace_root: Path, since_iso: str) -> dict:
90
+ """Return only entries with ``resolved_by_canopy_at`` >= ``since_iso``.
91
+
92
+ Comparison is lexicographic on the ISO 8601 Z strings (safe because they
93
+ are always produced in the same zero-padded format by ``_now_iso``).
94
+ """
95
+ log = load(workspace_root)
96
+ return {
97
+ tid: entry
98
+ for tid, entry in log.items()
99
+ if isinstance(entry, dict)
100
+ and entry.get("resolved_by_canopy_at", "") >= since_iso
101
+ }
@@ -0,0 +1,286 @@
1
+ """triage(author) — the agent's daily entry point.
2
+
3
+ Enumerates open PRs across all configured repos, groups by feature lane
4
+ (explicit from features.json or implicit by shared branch name), runs
5
+ each group's review comments through the temporal classifier, and tags
6
+ each feature with a priority tier:
7
+
8
+ changes_requested > review_required_with_bot_comments
9
+ > review_required
10
+ > approved
11
+
12
+ Designed for the user's morning workflow: ``canopy triage`` returns a
13
+ single ordered list of "what needs my attention right now".
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from ..integrations import github as gh
20
+ from ..workspace.workspace import Workspace
21
+ from . import slots as slots_mod
22
+ from .aliases import _resolve_owner_slug
23
+ from .errors import BlockerError
24
+ from .review_filter import classify_threads
25
+
26
+
27
+ _PRIORITY_ORDER = {
28
+ "changes_requested": 0,
29
+ "review_required_with_bot_comments": 1,
30
+ "review_required": 2,
31
+ "approved": 3,
32
+ "unknown": 4,
33
+ }
34
+
35
+
36
+ def triage(
37
+ workspace: Workspace,
38
+ author: str = "@me",
39
+ repos: list[str] | None = None,
40
+ ) -> dict[str, Any]:
41
+ """Return prioritized list of features needing user attention.
42
+
43
+ Args:
44
+ workspace: loaded workspace.
45
+ author: GitHub username/handle to filter PRs by; ``@me`` is
46
+ the gh CLI shorthand for the authenticated user.
47
+ repos: subset of canopy repos to scan (default: all).
48
+
49
+ Returns:
50
+ ``{author, canonical_feature, features: [{feature, linear_issue,
51
+ linear_url, linear_title, priority, is_canonical, physical_state,
52
+ repos: {<r>: {pr_number, pr_url, pr_title, branch, review_decision,
53
+ actionable_count, likely_resolved_count, has_actionable_bot_thread,
54
+ physical_state, path}}}]}`` ordered most-urgent first.
55
+
56
+ ``physical_state`` per feature is ``canonical | warm | cold | none``
57
+ (none = no worktree, branch may not even be checked out anywhere).
58
+ Per-repo ``physical_state`` + ``path`` lets the agent decide
59
+ whether to switch first or just `canopy_run` against the recorded
60
+ path.
61
+
62
+ Raises:
63
+ BlockerError: if no GitHub transport is available, or if a
64
+ requested repo is unknown.
65
+ """
66
+ target_repos = _select_repos(workspace, repos)
67
+ prs_by_repo = _fetch_open_prs(workspace, target_repos, author)
68
+ feature_groups = _group_by_feature(workspace, prs_by_repo)
69
+ state = slots_mod.read_state(workspace)
70
+ canonical_feature = state.canonical.feature if state and state.canonical else None
71
+ enriched = [_enrich(workspace, g, canonical_feature) for g in feature_groups]
72
+ enriched.sort(key=lambda f: _PRIORITY_ORDER.get(f["priority"], 99))
73
+ return {
74
+ "author": author,
75
+ "canonical_feature": canonical_feature,
76
+ "features": enriched,
77
+ }
78
+
79
+
80
+ def _fetch_open_prs(
81
+ workspace: Workspace, target_repos: list[str], author: str,
82
+ ) -> dict[str, list[dict]]:
83
+ out: dict[str, list[dict]] = {}
84
+ for repo_name in target_repos:
85
+ try:
86
+ owner, slug = _resolve_owner_slug(workspace, repo_name)
87
+ except BlockerError:
88
+ # Repo with no parseable github remote — skip silently
89
+ out[repo_name] = []
90
+ continue
91
+ try:
92
+ out[repo_name] = gh.list_open_prs(
93
+ workspace.config.root, owner, slug, author=author,
94
+ )
95
+ except gh.GitHubNotConfiguredError as e:
96
+ from .errors import FixAction
97
+ payload = e.payload or {}
98
+ fix_actions = [
99
+ FixAction(action=fa["action"], args=fa.get("args", {}),
100
+ safe=fa.get("safe", True), preview=fa.get("preview"))
101
+ for fa in payload.get("fix_actions", [])
102
+ ]
103
+ raise BlockerError(
104
+ code=payload.get("code", "github_not_configured"),
105
+ what=payload.get("what", str(e)),
106
+ fix_actions=fix_actions,
107
+ details={"repo": repo_name},
108
+ )
109
+ return out
110
+
111
+
112
+ def _group_by_feature(
113
+ workspace: Workspace, prs_by_repo: dict[str, list[dict]],
114
+ ) -> list[dict]:
115
+ """Group PRs into feature lanes.
116
+
117
+ Strategy:
118
+ 1. Build (repo, branch) → pr index.
119
+ 2. For each explicit feature in features.json, claim PRs whose
120
+ branch matches the lane's expected branch *for that repo*
121
+ (using the per-repo ``branches`` override map when set, else
122
+ feature name). This is what groups
123
+ ``auth-flow`` (api) + ``auth-flow-v2`` (ui) into one
124
+ feature lane.
125
+ 3. Remaining (repo, branch) pairs that weren't consumed become
126
+ implicit features: each branch becomes a feature, multi-repo
127
+ when the same branch appears in 2+ repos, single-repo otherwise.
128
+ """
129
+ from ..features.coordinator import FeatureCoordinator
130
+
131
+ by_repo_branch: dict[tuple[str, str], dict] = {}
132
+ for repo_name, prs in prs_by_repo.items():
133
+ for pr in prs:
134
+ branch = pr.get("head_branch") or ""
135
+ if not branch:
136
+ continue
137
+ by_repo_branch[(repo_name, branch)] = pr
138
+
139
+ coord = FeatureCoordinator(workspace)
140
+ features = coord._load_features()
141
+ consumed: set[tuple[str, str]] = set()
142
+ groups: list[dict] = []
143
+
144
+ for feature_name, feature_data in features.items():
145
+ if feature_data.get("status") != "active":
146
+ continue
147
+ feature_repos = list(feature_data.get("repos") or [])
148
+ branches_map = feature_data.get("branches") or {}
149
+
150
+ repos_for_feature: dict[str, dict] = {}
151
+ for repo_name in feature_repos:
152
+ expected_branch = branches_map.get(repo_name, feature_name)
153
+ key = (repo_name, expected_branch)
154
+ if key in by_repo_branch and key not in consumed:
155
+ repos_for_feature[repo_name] = by_repo_branch[key]
156
+ consumed.add(key)
157
+
158
+ if not repos_for_feature:
159
+ continue
160
+ groups.append({
161
+ "feature": feature_name,
162
+ "linear_issue": feature_data.get("linear_issue", ""),
163
+ "linear_url": feature_data.get("linear_url", ""),
164
+ "linear_title": feature_data.get("linear_title", ""),
165
+ "repos": repos_for_feature,
166
+ })
167
+
168
+ # Remaining (repo, branch) pairs become implicit feature groups.
169
+ # Same branch across repos → one group; otherwise per-branch group.
170
+ remaining_by_branch: dict[str, dict[str, dict]] = {}
171
+ for (repo_name, branch), pr in by_repo_branch.items():
172
+ if (repo_name, branch) in consumed:
173
+ continue
174
+ remaining_by_branch.setdefault(branch, {})[repo_name] = pr
175
+
176
+ for branch, repos in remaining_by_branch.items():
177
+ groups.append({
178
+ "feature": branch,
179
+ "linear_issue": "",
180
+ "linear_url": "",
181
+ "linear_title": "",
182
+ "repos": repos,
183
+ })
184
+
185
+ return groups
186
+
187
+
188
+ def _enrich(
189
+ workspace: Workspace, group: dict, canonical_feature: str | None,
190
+ ) -> dict:
191
+ feature_name = group["feature"]
192
+ is_canonical = canonical_feature == feature_name
193
+ per_repo: dict[str, dict] = {}
194
+ for canopy_repo, pr in group["repos"].items():
195
+ owner, slug = _resolve_owner_slug(workspace, canopy_repo)
196
+ comments, _resolved = gh.get_review_comments(
197
+ workspace.config.root, owner, slug, pr["number"],
198
+ )
199
+ state = workspace.get_repo(canopy_repo)
200
+ classification = classify_threads(
201
+ comments, state.abs_path, pr.get("head_branch") or "",
202
+ )
203
+ actionable = classification["actionable_threads"]
204
+
205
+ # Physical state per repo: where this feature lives right now.
206
+ slot_id = slots_mod.slot_for_feature(workspace, feature_name)
207
+ wt = (
208
+ slots_mod.slot_worktree_path(workspace, slot_id, canopy_repo)
209
+ if slot_id else None
210
+ )
211
+ if is_canonical:
212
+ phys = "canonical"
213
+ path = str(state.abs_path.resolve())
214
+ elif wt is not None and wt.exists() and (wt / ".git").exists():
215
+ phys = "warm"
216
+ path = str(wt.resolve())
217
+ else:
218
+ phys = "cold"
219
+ path = "" # no on-disk home yet; switch will create one
220
+
221
+ per_repo[canopy_repo] = {
222
+ "pr_number": pr["number"],
223
+ "pr_url": pr.get("url", ""),
224
+ "pr_title": pr.get("title", ""),
225
+ "branch": pr.get("head_branch", ""),
226
+ "review_decision": pr.get("review_decision", ""),
227
+ "actionable_count": len(actionable),
228
+ "likely_resolved_count": len(classification["likely_resolved_threads"]),
229
+ "has_actionable_bot_thread": any(
230
+ t.get("author_type") == "Bot" for t in actionable
231
+ ),
232
+ "physical_state": phys,
233
+ "path": path,
234
+ }
235
+
236
+ # Top-level physical_state is the highest-resolution per-repo state.
237
+ # canonical > warm > cold. (If repos disagree we report the warmest.)
238
+ states = {r["physical_state"] for r in per_repo.values()}
239
+ if "canonical" in states:
240
+ feat_phys = "canonical"
241
+ elif "warm" in states:
242
+ feat_phys = "warm" if states <= {"warm", "cold"} else "mixed"
243
+ else:
244
+ feat_phys = "cold"
245
+
246
+ return {
247
+ "feature": feature_name,
248
+ "linear_issue": group["linear_issue"],
249
+ "linear_url": group["linear_url"],
250
+ "linear_title": group["linear_title"],
251
+ "priority": _compute_priority(per_repo),
252
+ "is_canonical": is_canonical,
253
+ "physical_state": feat_phys,
254
+ "repos": per_repo,
255
+ }
256
+
257
+
258
+ def _compute_priority(per_repo: dict[str, dict]) -> str:
259
+ decisions = {info.get("review_decision", "") for info in per_repo.values()}
260
+ bot_actionable = any(
261
+ info.get("has_actionable_bot_thread") for info in per_repo.values()
262
+ )
263
+
264
+ if "CHANGES_REQUESTED" in decisions:
265
+ return "changes_requested"
266
+ non_empty = {d for d in decisions if d}
267
+ if non_empty and non_empty <= {"APPROVED"}:
268
+ return "approved"
269
+ if bot_actionable:
270
+ return "review_required_with_bot_comments"
271
+ return "review_required"
272
+
273
+
274
+ def _select_repos(workspace: Workspace, requested: list[str] | None) -> list[str]:
275
+ all_names = [r.config.name for r in workspace.repos]
276
+ if requested is None:
277
+ return all_names
278
+ unknown = [r for r in requested if r not in set(all_names)]
279
+ if unknown:
280
+ raise BlockerError(
281
+ code="unknown_repo",
282
+ what=f"unknown repos: {', '.join(unknown)}",
283
+ expected={"available_repos": sorted(all_names)},
284
+ details={"requested": list(requested)},
285
+ )
286
+ return list(requested)
@@ -0,0 +1,5 @@
1
+ """Agent-facing surface: tools that exist primarily to make agent tool
2
+ calls mistake-proof (path resolution, ad-hoc command exec, etc.)."""
3
+ from .runner import run_in_repo
4
+
5
+ __all__ = ["run_in_repo"]