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,607 @@
1
+ """feature_state(feature) — single source of truth for the dashboard CTAs.
2
+
3
+ Composes drift detection (P1) + dirty/branch state (workspace) + ahead/behind
4
+ (git) + temporal-filtered review comments (P4) + recorded preflight result
5
+ (``preflight_state``) + GitHub PR data (gh CLI fallback or MCP) into one
6
+ of these states:
7
+
8
+ drifted -- branches not on the feature; first thing to fix
9
+ needs_work -- review feedback exists (CHANGES_REQUESTED or
10
+ actionable threads from any reviewer)
11
+ in_progress -- aligned, dirty tree, no fresh preflight
12
+ ready_to_commit -- aligned, dirty tree, preflight passed for current HEAD
13
+ ready_to_push -- aligned, clean, ahead of remote
14
+ awaiting_review -- aligned, clean, pushed, PRs open, no actionable threads
15
+ approved -- all PRs approved
16
+ no_prs -- aligned, clean, no PRs anywhere
17
+
18
+ The state result also carries a ``next_actions`` list — the dashboard
19
+ renders the first one as the primary CTA, the rest as secondary. Same
20
+ data the agent uses to decide what to do next, so the human and the
21
+ agent stay in lockstep.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from ..git import repo as git
29
+ from ..integrations import github as gh
30
+ from ..workspace.workspace import Workspace
31
+ from .aliases import (
32
+ repos_for_feature, resolve_feature, _resolve_owner_slug,
33
+ )
34
+ from .augments import bot_authors
35
+ from .bot_resolutions import resolutions_for_feature
36
+ from .preflight_state import is_fresh
37
+ from .review_filter import classify_threads
38
+
39
+
40
+ def feature_state(workspace: Workspace, feature: str) -> dict[str, Any]:
41
+ """Compute the feature's current state + suggested next actions.
42
+
43
+ Args:
44
+ workspace: loaded workspace.
45
+ feature: feature alias (resolved through the standard alias layer).
46
+
47
+ Returns ``{feature, state, summary, next_actions, warnings}`` —
48
+ summary fields aggregate per-repo state so the dashboard can render
49
+ a feature card without re-querying.
50
+ """
51
+ feature_name = resolve_feature(workspace, feature)
52
+ workspace.refresh()
53
+
54
+ repo_branches = repos_for_feature(workspace, feature_name)
55
+ if not repo_branches:
56
+ return _shell_result(feature_name, "no_prs",
57
+ note="no repos resolved for feature")
58
+
59
+ # A worktree-backed feature physically lives at its worktree path,
60
+ # regardless of which feature is "active" right now. Resolve per-repo
61
+ # paths up front so drift + per-repo facts both check the right tree.
62
+ repo_paths, has_worktrees = resolve_repo_paths(
63
+ workspace, feature_name, repo_branches,
64
+ )
65
+
66
+ # Drift check from LIVE git state (not heads.json, which may be empty
67
+ # if the post-checkout hook hasn't run). The hook + heads.json power
68
+ # canopy drift's fast path; feature_state prefers correctness.
69
+ drift_info = _live_drift(workspace, repo_branches, repo_paths)
70
+ if drift_info["drifted_repos"] or drift_info["missing_repos"]:
71
+ return _drifted_result(feature_name, drift_info, has_worktrees=has_worktrees)
72
+
73
+ # Aligned. Gather per-repo facts.
74
+ per_repo = _per_repo_facts(workspace, feature_name, repo_branches, repo_paths)
75
+ summary = _summarize(per_repo)
76
+ preflight_fresh, preflight_entry = is_fresh(
77
+ workspace, feature_name, repo_branches,
78
+ )
79
+ summary["preflight"] = _preflight_summary(preflight_entry, preflight_fresh)
80
+
81
+ state, next_actions, warnings = _decide_state(
82
+ feature_name, per_repo, summary, preflight_fresh, preflight_entry,
83
+ )
84
+
85
+ return {
86
+ "feature": feature_name,
87
+ "state": state,
88
+ "summary": summary,
89
+ "next_actions": next_actions,
90
+ "warnings": warnings,
91
+ }
92
+
93
+
94
+ def resolve_repo_paths(
95
+ workspace: Workspace, feature_name: str, repo_branches: dict[str, str],
96
+ ) -> tuple[dict[str, Path], bool]:
97
+ """Per-repo path resolution for state derivation.
98
+
99
+ Worktree-backed features always resolve to the worktree path, regardless
100
+ of activation status — a worktree IS the feature's home, the active flag
101
+ only governs implicit cwd in canopy_run/IDE openers.
102
+
103
+ Returns (paths_by_repo, has_any_worktrees). The flag drives downstream
104
+ UX choices (e.g. drifted-state next-action: switch vs realign).
105
+ """
106
+ from ..features.coordinator import FeatureCoordinator
107
+ coord = FeatureCoordinator(workspace)
108
+ try:
109
+ lane = coord.status(feature_name)
110
+ except Exception:
111
+ lane = None
112
+
113
+ paths: dict[str, Path] = {}
114
+ has_worktrees = False
115
+ for repo_name in repo_branches:
116
+ try:
117
+ state = workspace.get_repo(repo_name)
118
+ except KeyError:
119
+ continue
120
+ wt_path: Path | None = None
121
+ if lane is not None:
122
+ wt_str = (lane.repo_states.get(repo_name) or {}).get("worktree_path")
123
+ if wt_str:
124
+ candidate = Path(wt_str).resolve()
125
+ # ``worktree_for_branch`` returns the main repo path when the
126
+ # branch is checked out there, so candidate == state.abs_path
127
+ # means "no linked worktree — feature lives in the main tree."
128
+ if candidate.exists() and candidate != state.abs_path.resolve():
129
+ wt_path = candidate
130
+ has_worktrees = True
131
+ paths[repo_name] = wt_path if wt_path is not None else state.abs_path
132
+ return paths, has_worktrees
133
+
134
+
135
+ def _per_repo_facts(
136
+ workspace: Workspace, feature_name: str, repo_branches: dict[str, str],
137
+ repo_paths: dict[str, Path],
138
+ ) -> dict[str, dict]:
139
+ """Gather facts per repo: dirty, ahead/behind, PR, comments.
140
+
141
+ ``repo_paths`` resolves to the worktree path for worktree-backed
142
+ features, the main repo path otherwise. Without it, dirty/ahead/branch
143
+ checks would target main even when the feature lives in a worktree.
144
+ """
145
+ out: dict[str, dict] = {}
146
+ for repo_name, branch in repo_branches.items():
147
+ try:
148
+ state = workspace.get_repo(repo_name)
149
+ except KeyError:
150
+ continue
151
+ repo_path = repo_paths.get(repo_name, state.abs_path)
152
+
153
+ facts: dict[str, Any] = {
154
+ "branch": branch,
155
+ "exists_locally": git.branch_exists(repo_path, branch),
156
+ }
157
+ if not facts["exists_locally"]:
158
+ out[repo_name] = facts
159
+ continue
160
+
161
+ try:
162
+ facts["is_dirty"] = git.is_dirty(repo_path)
163
+ facts["dirty_count"] = git.dirty_file_count(repo_path)
164
+ except git.GitError:
165
+ facts["is_dirty"] = False
166
+ facts["dirty_count"] = 0
167
+
168
+ try:
169
+ facts["head_sha"] = git.sha_of(repo_path, branch)
170
+ except git.GitError:
171
+ facts["head_sha"] = ""
172
+
173
+ remote_ref = f"origin/{branch}"
174
+ facts["has_upstream"] = bool(git.sha_of(repo_path, remote_ref))
175
+ if facts["has_upstream"]:
176
+ try:
177
+ ahead, behind = git.divergence(repo_path, branch, remote_ref)
178
+ facts["ahead"] = ahead
179
+ facts["behind"] = behind
180
+ except Exception:
181
+ facts["ahead"] = 0
182
+ facts["behind"] = 0
183
+ else:
184
+ facts["ahead"] = 0
185
+ facts["behind"] = 0
186
+
187
+ # PR + comment data.
188
+ try:
189
+ owner, slug = _resolve_owner_slug(workspace, repo_name)
190
+ except Exception:
191
+ owner, slug = "", ""
192
+ facts["owner"] = owner
193
+ facts["repo_slug"] = slug
194
+ facts["pr"] = None
195
+ facts["actionable_count"] = 0
196
+ facts["actionable_human_count"] = 0
197
+ facts["actionable_bot_count"] = 0
198
+ facts["actionable_bot_threads"] = []
199
+ facts["likely_resolved_count"] = 0
200
+ facts["review_decision"] = ""
201
+ if owner and slug:
202
+ try:
203
+ pr = gh.find_pull_request(
204
+ workspace.config.root, owner, slug, branch,
205
+ )
206
+ except gh.GitHubNotConfiguredError:
207
+ pr = None
208
+ if pr:
209
+ facts["pr"] = pr
210
+ facts["review_decision"] = pr.get("review_decision", "")
211
+ # M10: CI check rollup. Best-effort — failures here
212
+ # default to ``no_checks`` rather than blocking the
213
+ # whole feature_state read.
214
+ try:
215
+ ci_status, _raw = gh.get_pr_checks(
216
+ workspace.config.root, owner, slug, pr["number"],
217
+ )
218
+ facts["ci_status"] = ci_status
219
+ except Exception:
220
+ facts["ci_status"] = {"status": "no_checks"}
221
+ try:
222
+ comments, _ = gh.get_review_comments(
223
+ workspace.config.root, owner, slug, pr["number"],
224
+ )
225
+ classification = classify_threads(comments, repo_path, branch)
226
+ actionable = classification["actionable_threads"]
227
+ facts["likely_resolved_count"] = len(
228
+ classification["likely_resolved_threads"],
229
+ )
230
+ bot_subs = bot_authors(workspace.config)
231
+ resolved_ids = set(
232
+ resolutions_for_feature(
233
+ workspace.config.root, feature_name,
234
+ ).keys()
235
+ )
236
+ bot_threads = [
237
+ t for t in actionable
238
+ if _is_bot_comment(t, bot_subs)
239
+ and str(t.get("id", "")) not in resolved_ids
240
+ ]
241
+ human_threads = [
242
+ t for t in actionable
243
+ if not _is_bot_comment(t, bot_subs)
244
+ ]
245
+ facts["actionable_human_count"] = len(human_threads)
246
+ facts["actionable_bot_count"] = len(bot_threads)
247
+ facts["actionable_bot_threads"] = bot_threads
248
+ facts["actionable_count"] = (
249
+ facts["actionable_human_count"] + facts["actionable_bot_count"]
250
+ )
251
+ except Exception:
252
+ pass
253
+
254
+ out[repo_name] = facts
255
+ return out
256
+
257
+
258
+ def _is_bot_comment(comment: dict, bot_substrings: list[str]) -> bool:
259
+ """Determine if a normalized review comment came from a bot.
260
+
261
+ With ``review_bots`` configured (M2 augment), require both
262
+ ``author_type == "Bot"`` AND a substring match against the configured
263
+ list. Without it, fall back to the GitHub-provided ``author_type``
264
+ alone — so unconfigured workspaces still benefit from basic bot
265
+ detection.
266
+ """
267
+ author_type = (comment.get("author_type") or "").lower()
268
+ is_typed_bot = author_type == "bot"
269
+ if not bot_substrings:
270
+ return is_typed_bot
271
+ author = (comment.get("author") or "").lower()
272
+ return is_typed_bot and any(sub in author for sub in bot_substrings)
273
+
274
+
275
+ def _summarize(per_repo: dict[str, dict]) -> dict[str, Any]:
276
+ dirty_repos = [r for r, f in per_repo.items() if f.get("is_dirty")]
277
+ ahead_repos = {
278
+ r: f.get("ahead", 0) for r, f in per_repo.items() if f.get("ahead", 0) > 0
279
+ }
280
+ actionable_total = sum(f.get("actionable_count", 0) for f in per_repo.values())
281
+ actionable_human_total = sum(
282
+ f.get("actionable_human_count", 0) for f in per_repo.values()
283
+ )
284
+ actionable_bot_total = sum(
285
+ f.get("actionable_bot_count", 0) for f in per_repo.values()
286
+ )
287
+ likely_resolved_total = sum(
288
+ f.get("likely_resolved_count", 0) for f in per_repo.values()
289
+ )
290
+ decisions = {
291
+ r: f.get("review_decision", "") for r, f in per_repo.items() if f.get("pr")
292
+ }
293
+ pr_count = sum(1 for f in per_repo.values() if f.get("pr"))
294
+ ci_per_repo = {
295
+ r: f["ci_status"] for r, f in per_repo.items()
296
+ if f.get("pr") and f.get("ci_status")
297
+ }
298
+ return {
299
+ "dirty_repos": dirty_repos,
300
+ "ahead_repos": ahead_repos,
301
+ "actionable_count": actionable_total,
302
+ "actionable_human_count": actionable_human_total,
303
+ "actionable_bot_count": actionable_bot_total,
304
+ "likely_resolved_count": likely_resolved_total,
305
+ "review_decisions": decisions,
306
+ "pr_count": pr_count,
307
+ "repos": {r: {k: v for k, v in f.items() if k not in ("pr", "actionable_bot_threads")}
308
+ for r, f in per_repo.items()},
309
+ "prs": {r: f["pr"] for r, f in per_repo.items() if f.get("pr")},
310
+ # M10: per-repo CI rollup + a feature-level aggregate. The
311
+ # aggregate picks the worst across repos so a feature whose api
312
+ # is passing but ui is failing reports as "failing."
313
+ "ci_per_repo": ci_per_repo,
314
+ "ci_aggregate": _aggregate_ci(ci_per_repo),
315
+ }
316
+
317
+
318
+ def _aggregate_ci(ci_per_repo: dict[str, dict]) -> str:
319
+ """Worst-state-wins reduction across repos (M10)."""
320
+ if not ci_per_repo:
321
+ return "no_checks"
322
+ statuses = {(c.get("status") or "no_checks") for c in ci_per_repo.values()}
323
+ for severe in ("failing", "pending", "passing"):
324
+ if severe in statuses:
325
+ return severe
326
+ return "no_checks"
327
+
328
+
329
+ def _preflight_summary(entry, fresh: bool) -> dict[str, Any]:
330
+ if not entry:
331
+ return {"ran": False, "fresh": False}
332
+ return {
333
+ "ran": True,
334
+ "fresh": fresh,
335
+ "passed": entry.get("passed", False),
336
+ "ran_at": entry.get("ran_at", ""),
337
+ }
338
+
339
+
340
+ def _decide_state(
341
+ feature_name: str,
342
+ per_repo: dict[str, dict],
343
+ summary: dict[str, Any],
344
+ preflight_fresh: bool,
345
+ preflight_entry,
346
+ ) -> tuple[str, list[dict], list[dict]]:
347
+ decisions = summary["review_decisions"]
348
+ actionable = summary["actionable_count"]
349
+ actionable_human = summary.get("actionable_human_count", actionable)
350
+ actionable_bot = summary.get("actionable_bot_count", 0)
351
+ dirty = bool(summary["dirty_repos"])
352
+ ahead = bool(summary["ahead_repos"])
353
+ pr_count = summary["pr_count"]
354
+ warnings: list[dict] = []
355
+ next_actions: list[dict] = []
356
+
357
+ if dirty:
358
+ if preflight_fresh and preflight_entry and preflight_entry.get("passed"):
359
+ state = "ready_to_commit"
360
+ next_actions = [
361
+ {"action": "commit", "args": {"feature": feature_name},
362
+ "primary": True, "label": "Commit",
363
+ "preview": f"{len(summary['dirty_repos'])} repo(s) staged"},
364
+ {"action": "preflight", "args": {"feature": feature_name},
365
+ "primary": False, "label": "Re-run preflight"},
366
+ ]
367
+ else:
368
+ state = "in_progress"
369
+ if preflight_entry and not preflight_fresh:
370
+ warnings.append({
371
+ "code": "preflight_stale",
372
+ "what": "preflight result is stale (HEAD has moved since last run)",
373
+ })
374
+ next_actions = [
375
+ {"action": "preflight", "args": {"feature": feature_name},
376
+ "primary": True, "label": "Run preflight"},
377
+ {"action": "stash", "args": {"feature": feature_name},
378
+ "primary": False, "label": "Stash changes"},
379
+ ]
380
+ return state, next_actions, warnings
381
+
382
+ # Clean working tree from here on.
383
+ if ahead:
384
+ # If branch isn't pushed yet (no upstream OR ahead > 0),
385
+ # the next action is push.
386
+ next_actions = [
387
+ {"action": "push", "args": {"feature": feature_name},
388
+ "primary": True, "label": "Push",
389
+ "preview": ", ".join(f"{r}: +{n}" for r, n in summary['ahead_repos'].items())},
390
+ ]
391
+ # If PRs already exist + we have actionable comments, also surface
392
+ # 'address review comments' as secondary.
393
+ if actionable > 0:
394
+ next_actions.append({
395
+ "action": "address_review_comments",
396
+ "args": {"feature": feature_name},
397
+ "primary": False,
398
+ "label": "Address review comments",
399
+ })
400
+ return "ready_to_push", next_actions, warnings
401
+
402
+ # Aligned, clean, caught up to remote (or nothing to push).
403
+ # Human signals (CHANGES_REQUESTED reviews, or actionable human threads)
404
+ # block on `needs_work`; bot threads alone route to `awaiting_bot_resolution`.
405
+ if actionable_human > 0 or _any_changes_requested(decisions):
406
+ next_actions = [
407
+ {"action": "address_review_comments",
408
+ "args": {"feature": feature_name},
409
+ "primary": True, "label": "Address review comments",
410
+ "preview": f"{actionable_human} human thread(s), {actionable_bot} bot thread(s)"
411
+ if actionable_bot else f"{actionable_human} human thread(s)"},
412
+ {"action": "comments", "args": {"feature": feature_name},
413
+ "primary": False, "label": "View comments"},
414
+ ]
415
+ return "needs_work", next_actions, warnings
416
+
417
+ if pr_count == 0:
418
+ # Aligned, clean, but no PRs — likely needs PR creation
419
+ next_actions = [
420
+ {"action": "pr_create", "args": {"feature": feature_name},
421
+ "primary": True, "label": "Open PR(s)"},
422
+ ]
423
+ return "no_prs", next_actions, warnings
424
+
425
+ ci_aggregate = summary.get("ci_aggregate", "no_checks")
426
+ ci_per_repo = summary.get("ci_per_repo") or {}
427
+ non_empty = {d for d in decisions.values() if d}
428
+ if non_empty and non_empty <= {"APPROVED"}:
429
+ # M10 CI matrix: approved + CI is the merge gate.
430
+ if ci_aggregate == "failing":
431
+ failing_names = sorted(
432
+ name for repo, ci in ci_per_repo.items()
433
+ for name in (ci.get("required_failing") or [])
434
+ )
435
+ next_actions = [
436
+ {"action": "investigate_ci",
437
+ "args": {"feature": feature_name},
438
+ "primary": True, "label": "Investigate failing CI",
439
+ "preview": ", ".join(failing_names) or "see Checks tab"},
440
+ {"action": "comments", "args": {"feature": feature_name},
441
+ "primary": False, "label": "View comments"},
442
+ ]
443
+ # Failing CI overrides the "approved" badge — same intent as
444
+ # a CHANGES_REQUESTED review.
445
+ return "needs_work", next_actions, warnings
446
+ if ci_aggregate == "pending":
447
+ pending_names = sorted(
448
+ name for repo, ci in ci_per_repo.items()
449
+ for name in (ci.get("required_pending") or [])
450
+ )
451
+ next_actions = [
452
+ {"action": "wait_for_ci",
453
+ "args": {"feature": feature_name},
454
+ "primary": True, "label": "Waiting on CI",
455
+ "preview": ", ".join(pending_names) or "checks running"},
456
+ {"action": "refresh", "args": {"feature": feature_name},
457
+ "primary": False, "label": "Refresh"},
458
+ ]
459
+ return "awaiting_ci", next_actions, warnings
460
+
461
+ next_actions = [
462
+ {"action": "merge", "args": {"feature": feature_name},
463
+ "primary": True, "label": "Merge",
464
+ "preview": "all PRs approved (manual or via UI)"},
465
+ ]
466
+ # Bots may still have unresolved nits — surface as a non-gating
467
+ # secondary CTA. State stays `approved` (human approval is the merge
468
+ # gate; bot nits are a side-channel).
469
+ if actionable_bot > 0:
470
+ next_actions.append({
471
+ "action": "address_bot_comments",
472
+ "args": {"feature": feature_name},
473
+ "primary": False, "label": "Address bot comments",
474
+ "preview": f"{actionable_bot} unresolved bot thread(s)",
475
+ })
476
+ return "approved", next_actions, warnings
477
+
478
+ # No human action pending, PR open, not yet approved. Bot nits get their
479
+ # own state so the agent + dashboard can distinguish "still review-pending"
480
+ # from "human is silent but bots flagged things."
481
+ if actionable_bot > 0:
482
+ next_actions = [
483
+ {"action": "address_bot_comments",
484
+ "args": {"feature": feature_name},
485
+ "primary": True, "label": "Address bot comments",
486
+ "preview": f"{actionable_bot} bot thread(s)"},
487
+ {"action": "comments", "args": {"feature": feature_name},
488
+ "primary": False, "label": "View comments"},
489
+ ]
490
+ return "awaiting_bot_resolution", next_actions, warnings
491
+
492
+ next_actions = [
493
+ {"action": "refresh", "args": {"feature": feature_name},
494
+ "primary": True, "label": "Refresh",
495
+ "preview": "waiting on review"},
496
+ ]
497
+ return "awaiting_review", next_actions, warnings
498
+
499
+
500
+ def _any_changes_requested(decisions: dict[str, str]) -> bool:
501
+ return "CHANGES_REQUESTED" in decisions.values()
502
+
503
+
504
+ def _live_drift(
505
+ workspace: Workspace, repo_branches: dict[str, str],
506
+ repo_paths: dict[str, Path],
507
+ ) -> dict[str, Any]:
508
+ """Check actual git state vs expected per repo against the resolved path.
509
+
510
+ For worktree-backed features the resolved path is the worktree, so the
511
+ branch check is against the worktree's HEAD. For main-tree features it's
512
+ the main repo. Either way, the branch check is targeted correctly.
513
+
514
+ Returns ``{drifted_repos, missing_repos, expected, actual}``.
515
+ """
516
+ drifted: list[str] = []
517
+ missing: list[str] = []
518
+ expected: dict[str, str] = {}
519
+ actual: dict[str, str | None] = {}
520
+ for repo_name, expected_branch in repo_branches.items():
521
+ expected[repo_name] = expected_branch
522
+ try:
523
+ state = workspace.get_repo(repo_name)
524
+ except KeyError:
525
+ missing.append(repo_name)
526
+ actual[repo_name] = None
527
+ continue
528
+ check_path = repo_paths.get(repo_name, state.abs_path)
529
+ if not git.branch_exists(check_path, expected_branch):
530
+ missing.append(repo_name)
531
+ actual[repo_name] = None
532
+ continue
533
+ try:
534
+ current = git.current_branch(check_path)
535
+ except git.GitError:
536
+ current = None
537
+ actual[repo_name] = current
538
+ if current != expected_branch:
539
+ drifted.append(repo_name)
540
+ return {
541
+ "drifted_repos": drifted,
542
+ "missing_repos": missing,
543
+ "expected": expected,
544
+ "actual": actual,
545
+ }
546
+
547
+
548
+ def _drifted_result(
549
+ feature_name: str, drift_info: dict, *, has_worktrees: bool = False,
550
+ ) -> dict[str, Any]:
551
+ drifted = drift_info["drifted_repos"]
552
+ missing = drift_info["missing_repos"]
553
+
554
+ # F-12: post-Wave 2.9, the canonical-slot model handles both worktree
555
+ # and main-tree recovery via ``switch``. The deprecated ``realign``
556
+ # action is no longer surfaced as the primary CTA for either case;
557
+ # ``switch`` re-establishes the feature context regardless of where
558
+ # the feature lives. ``done`` stays as a secondary CTA on the
559
+ # worktree path so users can intentionally drop a broken worktree.
560
+ if has_worktrees:
561
+ next_actions = [
562
+ {"action": "switch", "args": {"feature": feature_name},
563
+ "primary": True, "label": "Switch",
564
+ "preview": (
565
+ "worktree is on the wrong branch — switch to re-establish"
566
+ " the feature context"
567
+ )},
568
+ {"action": "done", "args": {"feature": feature_name},
569
+ "primary": False, "label": "Clean up worktree",
570
+ "preview": "remove the worktree if you no longer need it"},
571
+ ]
572
+ else:
573
+ next_actions = [
574
+ {"action": "switch", "args": {"feature": feature_name},
575
+ "primary": True, "label": "Switch",
576
+ "preview": (
577
+ f"checkout expected branch in "
578
+ f"{', '.join(drifted + missing)}"
579
+ )},
580
+ ]
581
+
582
+ return {
583
+ "feature": feature_name,
584
+ "state": "drifted",
585
+ "summary": {
586
+ "alignment": {
587
+ "aligned": False,
588
+ "expected": drift_info["expected"],
589
+ "actual": drift_info["actual"],
590
+ "drifted_repos": drifted,
591
+ "missing_repos": missing,
592
+ "has_worktrees": has_worktrees,
593
+ },
594
+ },
595
+ "next_actions": next_actions,
596
+ "warnings": [],
597
+ }
598
+
599
+
600
+ def _shell_result(feature_name: str, state: str, *, note: str = "") -> dict[str, Any]:
601
+ return {
602
+ "feature": feature_name,
603
+ "state": state,
604
+ "summary": {"note": note} if note else {},
605
+ "next_actions": [],
606
+ "warnings": [],
607
+ }